fix(withings,scanwatch): Notifications now use app icons

This commit is contained in:
d3vv3
2026-04-09 20:21:00 +02:00
parent 0ce06770ac
commit 75052fc070
14 changed files with 435 additions and 49 deletions
@@ -180,6 +180,7 @@ public class GBDeviceService implements DeviceService {
.putExtra(EXTRA_NOTIFICATION_SOURCENAME, notificationSpec.sourceName)
.putExtra(EXTRA_NOTIFICATION_SOURCEAPPID, notificationSpec.sourceAppId)
.putExtra(EXTRA_NOTIFICATION_ICONID, notificationSpec.iconId)
.putExtra(EXTRA_NOTIFICATION_ICONPACKAGEID, notificationSpec.iconPackageId)
.putExtra(NOTIFICATION_PICTURE_PATH, notificationSpec.picturePath)
.putExtra(EXTRA_NOTIFICATION_DNDSUPPRESSED, notificationSpec.dndSuppressed)
.putExtra(EXTRA_NOTIFICATION_CHANNEL_ID, notificationSpec.channelId)
@@ -95,6 +95,7 @@ public class AppNotificationType extends HashMap<String, NotificationType> {
put("org.telegram.messenger.beta", NotificationType.TELEGRAM);
put("org.telegram.messenger.web", NotificationType.TELEGRAM);
put("org.telegram.plus", NotificationType.TELEGRAM); // "Plus Messenger"
put("org.forkgram.messenger", NotificationType.TELEGRAM);
put("org.thunderdog.challegram", NotificationType.TELEGRAM);
put("nekox.messenger", NotificationType.TELEGRAM);
put("tw.nekomimi.nekogram", NotificationType.TELEGRAM);
@@ -100,6 +100,7 @@ public interface DeviceService extends EventHandler {
String EXTRA_NOTIFICATION_TYPE = "notification_type";
String EXTRA_NOTIFICATION_ACTIONS = "notification_actions";
String EXTRA_NOTIFICATION_ICONID = "notification_iconid";
String EXTRA_NOTIFICATION_ICONPACKAGEID = "notification_iconpackageid";
String NOTIFICATION_PICTURE_PATH = "notification_picture_path";
String EXTRA_NOTIFICATION_DNDSUPPRESSED = "notification_dndsuppressed";
String EXTRA_NOTIFICATION_CHANNEL_ID = "notification_channel_id";
@@ -50,6 +50,11 @@ public class NotificationSpec {
*/
public int iconId;
/**
* The package that owns the notification icon resource.
*/
public String iconPackageId;
public String picturePath;
public int dndSuppressed;
@@ -949,6 +949,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
notificationSpec.flags = intentCopy.getIntExtra(EXTRA_NOTIFICATION_FLAGS, 0);
notificationSpec.sourceAppId = intentCopy.getStringExtra(EXTRA_NOTIFICATION_SOURCEAPPID);
notificationSpec.iconId = intentCopy.getIntExtra(EXTRA_NOTIFICATION_ICONID, 0);
notificationSpec.iconPackageId = intentCopy.getStringExtra(EXTRA_NOTIFICATION_ICONPACKAGEID);
notificationSpec.picturePath = intent.getStringExtra(NOTIFICATION_PICTURE_PATH);
notificationSpec.dndSuppressed = intentCopy.getIntExtra(EXTRA_NOTIFICATION_DNDSUPPRESSED, 0);
notificationSpec.channelId = intentCopy.getStringExtra(EXTRA_NOTIFICATION_CHANNEL_ID);
@@ -32,9 +32,15 @@ public class IconHelper {
}
public static byte[] getIconBytesFromDrawable(Drawable drawable, int width, int height) {
Bitmap bitmap = BitmapUtil.toBitmap(drawable);
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, width, height, true);
return toByteArray(scaledBitmap);
return getIconBytesFromBitmap(BitmapUtil.toBitmap(drawable), width, height, true);
}
public static byte[] getIconBytesFromBitmap(final Bitmap bitmap, final int width, final int height) {
return getIconBytesFromBitmap(bitmap, width, height, true);
}
public static byte[] getIconBytesFromBitmap(final Bitmap bitmap, final int width, final int height, final boolean cropTransparentBounds) {
return toByteArray(renderBitmap(bitmap, width, height, cropTransparentBounds));
}
public static List<byte[]> splitImageData(byte[] imageData, int maxChunkSize) {
@@ -72,11 +78,74 @@ public class IconHelper {
return rawData;
}
private static Bitmap renderBitmap(final Bitmap source, final int width, final int height, final boolean cropTransparentBounds) {
final Bitmap sourceBitmap = cropTransparentBounds ? cropTransparentBounds(source) : source;
final Bitmap renderedBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final int sourceWidth = Math.max(1, sourceBitmap.getWidth());
final int sourceHeight = Math.max(1, sourceBitmap.getHeight());
final float scale = Math.min((float) width / sourceWidth, (float) height / sourceHeight);
final int renderWidth = Math.max(1, Math.round(sourceWidth * scale));
final int renderHeight = Math.max(1, Math.round(sourceHeight * scale));
final int left = (width - renderWidth) / 2;
final int top = (height - renderHeight) / 2;
final Bitmap scaledBitmap;
if (sourceWidth == renderWidth && sourceHeight == renderHeight) {
scaledBitmap = sourceBitmap;
} else {
scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, renderWidth, renderHeight, true);
}
for (int y = 0; y < renderHeight; y++) {
for (int x = 0; x < renderWidth; x++) {
renderedBitmap.setPixel(left + x, top + y, scaledBitmap.getPixel(x, y));
}
}
return renderedBitmap;
}
private static Bitmap cropTransparentBounds(final Bitmap bitmap) {
int minX = bitmap.getWidth();
int minY = bitmap.getHeight();
int maxX = -1;
int maxY = -1;
for (int y = 0; y < bitmap.getHeight(); y++) {
for (int x = 0; x < bitmap.getWidth(); x++) {
if (Color.alpha(bitmap.getPixel(x, y)) == 0) {
continue;
}
if (x < minX) {
minX = x;
}
if (y < minY) {
minY = y;
}
if (x > maxX) {
maxX = x;
}
if (y > maxY) {
maxY = y;
}
}
}
if (maxX < minX || maxY < minY) {
return bitmap;
}
if (minX == 0 && minY == 0 && maxX == bitmap.getWidth() - 1 && maxY == bitmap.getHeight() - 1) {
return bitmap;
}
return Bitmap.createBitmap(bitmap, minX, minY, maxX - minX + 1, maxY - minY + 1);
}
private static boolean shouldPixelbeAdded(int pixel) {
// Workout/source icons are often black on transparent. Luma-based thresholding
// drops black pixels and produces empty images. For Withings monochrome bitmaps,
// any visible (non-transparent) pixel should be set.
return Color.alpha(pixel) > 0;
return Color.alpha(pixel) >= 0x80;
}
private static byte setBit(byte bits, int position) {
@@ -479,7 +479,7 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
public void onSetCallState(CallSpec callSpec) {
if (callSpec.command == CallSpec.CALL_INCOMING) {
NotificationSpec notificationSpec = new NotificationSpec();
notificationSpec.sourceAppId = "incoming.call";
notificationSpec.sourceAppId = callSpec.sourceAppId != null ? callSpec.sourceAppId : "org.fossify.phone";
notificationSpec.title = callSpec.number;
notificationSpec.sender = callSpec.name;
notificationSpec.type = NotificationType.GENERIC_PHONE;
@@ -660,6 +660,8 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
return;
}
characteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE);
byte[] rawData = message.getRawData();
builder.writeChunkedData(characteristic, rawData, getMTU() - 3);
builder.queue();
@@ -882,7 +884,7 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
private void enableNotifications() {
// Enable ANCS bridge
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true)));
// Register the TAG_NOTIFICATIONS feature tag
WithingsMessage featureTagsMsg = new WithingsMessage(WithingsMessageType.SET_FEATURE_TAGS_DEPRECATED);
featureTagsMsg.addDataStructure(new FeatureTagsUserId(0));
@@ -40,7 +40,11 @@ public class GlyphId extends WithingsStructure {
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
// Write unicode codepoint as 4-byte little-endian
buffer.put((byte)(unicode & 0xFF));
buffer.put((byte)((unicode >> 8) & 0xFF));
buffer.put((byte)((unicode >> 16) & 0xFF));
buffer.put((byte)((unicode >> 24) & 0xFF));
}
@Override
@@ -21,21 +21,29 @@ import java.nio.ByteBuffer;
public class ImageData extends WithingsStructure {
byte [] imageData;
private boolean endOfMessage;
public void setImageData(byte[] imageData) {
this.imageData = imageData;
}
public void setEndOfMessage(boolean endOfMessage) {
this.endOfMessage = endOfMessage;
}
@Override
public boolean withEndOfMessage() {
return endOfMessage;
}
@Override
public short getLength() {
return imageData != null ? (short)(imageData.length + HEADER_SIZE) : HEADER_SIZE;
return imageData != null ? (short)(imageData.length + HEADER_SIZE + 1) : HEADER_SIZE + 1;
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
if (imageData != null) {
buffer.put(imageData);
}
addByteArrayWithLengthByte(buffer, imageData != null ? imageData : new byte[0]);
}
@Override
@@ -25,11 +25,15 @@ import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.IconHelper;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GlyphId;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.IncomingMessageHandler;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@@ -44,39 +48,84 @@ public class GlyphRequestHandler implements IncomingMessageHandler {
@Override
public void handleMessage(Message message) {
try {
GlyphId glyphId = message.getStructureByType(GlyphId.class);
ImageMetaData imageMetaData = message.getStructureByType(ImageMetaData.class);
Message reply = new WithingsMessage((short) (WithingsMessageType.GET_UNICODE_GLYPH | 0x4000));
reply.addDataStructure(glyphId);
reply.addDataStructure(imageMetaData);
ImageData imageData = new ImageData();
imageData.setImageData(createUnicodeImage(glyphId.getUnicode(), imageMetaData));
reply.addDataStructure(imageData);
logger.info("Sending reply to glyph request: " + reply);
support.sendToDevice(reply);
ImageMetaData requestedMetaData = message.getStructureByType(ImageMetaData.class);
// Collect all GlyphId structures from the request (watch can request multiple glyphs)
List<GlyphId> glyphIds = new ArrayList<>();
for (WithingsStructure structure : message.getDataStructures()) {
if (structure instanceof GlyphId) {
glyphIds.add((GlyphId) structure);
}
}
int requestedHeight = requestedMetaData.getHeight() & 0xFF;
int requestedWidth = requestedMetaData.getWidth() & 0xFF;
for (GlyphId glyphId : glyphIds) {
Message reply = new WithingsMessage((short) (WithingsMessageType.GET_UNICODE_GLYPH | 0x4000));
reply.addDataStructure(glyphId);
GlyphRenderResult renderResult = createUnicodeImage(glyphId.getUnicode(), requestedWidth, requestedHeight);
// Set response metadata with the actual rendered width (may be narrower than requested)
ImageMetaData responseMetaData = new ImageMetaData();
responseMetaData.setIndex(requestedMetaData.getIndex());
responseMetaData.setWidth((byte) renderResult.width);
responseMetaData.setHeight((byte) requestedHeight);
reply.addDataStructure(responseMetaData);
// Chunk image data into 64-byte blocks, matching official app behavior
List<byte[]> imageChunks = IconHelper.splitImageData(renderResult.data, 64);
for (int i = 0; i < imageChunks.size(); i++) {
ImageData imageDataStructure = new ImageData();
imageDataStructure.setImageData(imageChunks.get(i));
if (i == imageChunks.size() - 1) {
imageDataStructure.setEndOfMessage(true);
}
reply.addDataStructure(imageDataStructure);
}
logger.info("Sending reply to glyph request for U+{}: width={}, height={}, dataLen={}",
String.format("%04X", glyphId.getUnicode()), renderResult.width, requestedHeight,
renderResult.data.length);
support.sendToDevice(reply);
}
} catch (Exception e) {
logger.error("Failed to respond to glyph request.", e);
GB.toast("Failed to respond to glyph request:" + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.WARN);
}
}
private byte[] createUnicodeImage(long unicode, ImageMetaData metaData) {
String str = new String(Character.toChars((int)unicode));
private static class GlyphRenderResult {
final byte[] data;
final int width;
GlyphRenderResult(byte[] data, int width) {
this.data = data;
this.width = width;
}
}
private GlyphRenderResult createUnicodeImage(long unicode, int maxWidth, int height) {
String str = new String(Character.toChars((int) unicode));
Paint paint = new Paint();
paint.setTypeface(null);
Rect rect = new Rect();
paint.setTextSize(calculateTextsize(paint, metaData.getHeight()));
paint.setTextSize(calculateTextsize(paint, height));
paint.setAntiAlias(true);
paint.getTextBounds(str, 0, str.length(), rect);
paint.setColor(-1);
Paint.FontMetricsInt fontMetricsInt = paint.getFontMetricsInt();
int width = rect.width();
if (width <= 0) {
return new byte[0];
int renderedWidth = rect.width();
if (renderedWidth <= 0) {
// Even for zero-width characters, produce a 1-pixel-wide empty glyph
renderedWidth = 1;
}
Bitmap createBitmap = Bitmap.createBitmap(width, metaData.getHeight(), Bitmap.Config.ARGB_8888);
// Cap width to the requested maximum
int actualWidth = Math.min(renderedWidth, maxWidth);
Bitmap createBitmap = Bitmap.createBitmap(actualWidth, height, Bitmap.Config.ARGB_8888);
new Canvas(createBitmap).drawText(str, -rect.left, -fontMetricsInt.top, paint);
return IconHelper.toByteArray(createBitmap);
return new GlyphRenderResult(IconHelper.toByteArray(createBitmap), actualWidth);
}
private int calculateTextsize(Paint paint, int height) {
@@ -25,6 +25,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
@@ -60,9 +61,15 @@ public class NotificationRequestHandler implements IncomingMessageHandler {
byte[] imageData = getImageData(appId.getAppId(), imageMetaData);
ImageData imageDataStructure = new ImageData();
imageDataStructure.setImageData(imageData);
reply.addDataStructure(imageDataStructure);
List<byte[]> imageChunks = IconHelper.splitImageData(imageData, 64);
for (int i = 0; i < imageChunks.size(); i++) {
ImageData imageDataStructure = new ImageData();
imageDataStructure.setImageData(imageChunks.get(i));
if (i == imageChunks.size() - 1) {
imageDataStructure.setEndOfMessage(true);
}
reply.addDataStructure(imageDataStructure);
}
logger.info("Sending reply to notification request: " + reply);
support.sendToDevice(reply);
@@ -84,25 +91,30 @@ public class NotificationRequestHandler implements IncomingMessageHandler {
if (imageData == null) {
String packageName = sourceAppId;
if (packageName != null) {
packageName = packageName.replace("-msg", "").replace("-ringing", "").replace("-missed", "").replace("-gb", "");
packageName = packageName.replace("-msg", "").replace("-ringing", "").replace("-missed", "");
}
NotificationSpec notificationSpec = support.getNotificationProvider().getNotificationSpecForSourceAppId(sourceAppId);
if (notificationSpec != null && notificationSpec.sourceAppId != null) {
packageName = notificationSpec.sourceAppId;
}
String iconPackageName = packageName;
if (notificationSpec != null && notificationSpec.iconPackageId != null) {
iconPackageName = notificationSpec.iconPackageId;
}
logger.info("Resolving icon for sourceAppId='{}', packageName='{}'", sourceAppId, packageName);
logger.info("Resolving icon for sourceAppId='{}', packageName='{}', iconPackageName='{}'", sourceAppId, packageName, iconPackageName);
try {
Drawable icon = null;
if (notificationSpec != null && notificationSpec.iconId != 0) {
try {
Context sourcePackageContext = support.getContext().createPackageContext(packageName, 0);
Context sourcePackageContext = support.getContext().createPackageContext(iconPackageName, 0);
icon = sourcePackageContext.getResources().getDrawable(notificationSpec.iconId);
logger.info("Loaded specific iconId={} from package {}", notificationSpec.iconId, packageName);
logger.info("Loaded specific iconId={} from package {}", notificationSpec.iconId, iconPackageName);
} catch (Exception ex) {
logger.warn("Failed to load specific iconId={} from package {}, falling back to app icon", notificationSpec.iconId, packageName);
logger.warn("Failed to load specific iconId={} from package {}, falling back to app icon", notificationSpec.iconId, iconPackageName);
}
}
if (icon == null) {
@@ -141,4 +153,5 @@ public class NotificationRequestHandler implements IncomingMessageHandler {
}
return imageData;
}
}
@@ -34,6 +34,7 @@ public class NotificationProvider {
private static final Logger logger = LoggerFactory.getLogger(NotificationProvider.class);
private static final Pattern WITHINGS_SOURCE_APP_SUFFIX = Pattern.compile("-(msg|ringing|missed)$", Pattern.CASE_INSENSITIVE);
private static final String WITHINGS_DIALER_APP_ID = "dialerApp";
private static final String UNKNOWN_DEVICE_KEY = "unknown-withings-device";
private static final Map<String, NotificationState> NOTIFICATION_STATES = new ConcurrentHashMap<>();
private final WithingsBaseDeviceSupport support;
@@ -47,6 +48,7 @@ public class NotificationProvider {
final NotificationState state = getState();
if (spec.sourceAppId != null) {
state.latestNotificationByApp.put(normalizeSourceAppId(spec.sourceAppId), spec);
state.latestNotificationByApp.put(normalizeSourceAppId(getWithingsSourceAppId(spec)), spec);
}
NotificationSource notificationSource = new NotificationSource(spec.getId(),
AncsConstants.EVENT_ID_NOTIFICATION_ADDED,
@@ -106,13 +108,7 @@ public class NotificationProvider {
logger.debug("Handling attribute " + attribute.getAttributeID() + " with maxLength " + attribute.getAttributeLength());
String value = "";
if (requestedAttribute.getAttributeID() == 0) {
if (spec.type == NotificationType.GENERIC_PHONE) {
value = spec.sourceAppId + "-ringing";
} else if (spec.type == NotificationType.MAILBOX) {
value = spec.sourceAppId + "-missed";
} else {
value = spec.sourceAppId + "-msg";
}
value = getWithingsSourceAppId(spec);
}
if (requestedAttribute.getAttributeID() == 1) {
complete = true;
@@ -177,7 +173,7 @@ public class NotificationProvider {
// First try finding it in pending notifications (if still active)
for (NotificationSpec notificationSpec : state.pendingNotifications.values()) {
if (notificationSpec.sourceAppId != null && notificationSpec.sourceAppId.equalsIgnoreCase(normalizedSourceAppId)) {
if (matchesSourceAppId(notificationSpec, normalizedSourceAppId)) {
return notificationSpec;
}
}
@@ -204,6 +200,31 @@ public class NotificationProvider {
return WITHINGS_SOURCE_APP_SUFFIX.matcher(sourceAppId).replaceFirst("");
}
static String getWithingsSourceAppId(final NotificationSpec spec) {
if (spec == null || spec.sourceAppId == null) {
return null;
}
if (WITHINGS_SOURCE_APP_SUFFIX.matcher(spec.sourceAppId).find()) {
return spec.sourceAppId;
}
if (spec.type == NotificationType.GENERIC_PHONE) {
return WITHINGS_DIALER_APP_ID + "-ringing";
}
return spec.sourceAppId;
}
private boolean matchesSourceAppId(final NotificationSpec notificationSpec, final String normalizedSourceAppId) {
if (notificationSpec.sourceAppId != null && notificationSpec.sourceAppId.equalsIgnoreCase(normalizedSourceAppId)) {
return true;
}
final String withingsSourceAppId = normalizeSourceAppId(getWithingsSourceAppId(notificationSpec));
return withingsSourceAppId != null && withingsSourceAppId.equalsIgnoreCase(normalizedSourceAppId);
}
private byte mapNotificationType(NotificationType type) {
switch (type) {
case GENERIC_ALARM_CLOCK:
@@ -1,5 +1,11 @@
package nodomain.freeyourgadget.gadgetbridge.externalevents;
import android.app.Notification;
import android.os.Bundle;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import org.junit.Test;
import java.time.LocalTime;
@@ -8,9 +14,11 @@ import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity;
import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilter;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
import static nodomain.freeyourgadget.gadgetbridge.util.GB.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -169,4 +177,47 @@ public class NotificationListenerTest extends TestBase {
/* end= */ LocalTime.of(7, 0)
));
}
}
@Test
public void dissectNotificationTo_prefersMessagingMetadataForSenderAndBody() {
final NotificationListener listener = new NotificationListener();
final NotificationSpec spec = new NotificationSpec();
final Person sender = new Person.Builder().setName("Alice").build();
final Person user = new Person.Builder().setName("Me").build();
final Notification notification = new NotificationCompat.Builder(getContext(), "test")
.setContentTitle("Fallback title")
.setContentText("Fallback body")
.setStyle(new NotificationCompat.MessagingStyle(user)
.setConversationTitle("Family Group")
.addMessage("Latest message", System.currentTimeMillis(), sender))
.build();
listener.dissectNotificationTo(notification, spec, true);
assertEquals("Alice", spec.sender);
assertEquals("Family Group", spec.title);
assertEquals("Latest message", spec.body);
}
@Test
public void dissectNotificationTo_usesConversationTitleWhenTitleMissing() {
final NotificationListener listener = new NotificationListener();
final NotificationSpec spec = new NotificationSpec();
final Bundle extras = new Bundle();
extras.putCharSequence(NotificationCompat.EXTRA_CONVERSATION_TITLE, "Chat Room");
final Notification notification = new NotificationCompat.Builder(getContext(), "test")
.setExtras(extras)
.setStyle(new NotificationCompat.MessagingStyle(new Person.Builder().setName("Me").build())
.setConversationTitle("Chat Room")
.addMessage("Body text", System.currentTimeMillis(), new Person.Builder().setName("Bob").build()))
.build();
listener.dissectNotificationTo(notification, spec, true);
assertEquals("Chat Room", spec.title);
assertEquals("Bob", spec.sender);
assertEquals("Body text", spec.body);
}
}
@@ -0,0 +1,160 @@
/* Copyright (C) 2026 Frank Ertl
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.graphics.Bitmap;
import android.graphics.Color;
import org.junit.Test;
import nodomain.freeyourgadget.gadgetbridge.model.AppNotificationType;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.IconHelper;
import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
public class NotificationProviderTest extends TestBase {
@Test
public void testRegularMessageAppsStayRaw() {
NotificationSpec spec = new NotificationSpec();
spec.sourceAppId = "org.forkgram.messenger";
spec.type = NotificationType.TELEGRAM;
assertEquals("org.forkgram.messenger", NotificationProvider.getWithingsSourceAppId(spec));
}
@Test
public void testForkgramIsClassifiedAsTelegram() {
assertEquals(NotificationType.TELEGRAM, AppNotificationType.getInstance().get("org.forkgram.messenger"));
}
@Test
public void testWhatsappIsClassifiedAsWhatsapp() {
assertEquals(NotificationType.WHATSAPP, AppNotificationType.getInstance().get("com.whatsapp"));
}
@Test
public void testExplicitMsgSuffixIsPreserved() {
NotificationSpec spec = new NotificationSpec();
spec.sourceAppId = "org.fossify.messages-msg";
spec.type = NotificationType.GENERIC_SMS;
assertEquals("org.fossify.messages-msg", NotificationProvider.getWithingsSourceAppId(spec));
}
@Test
public void testIncomingCallsUseDialerAppId() {
NotificationSpec spec = new NotificationSpec();
spec.sourceAppId = "org.fossify.phone";
spec.type = NotificationType.GENERIC_PHONE;
assertEquals("dialerApp-ringing", NotificationProvider.getWithingsSourceAppId(spec));
}
@Test
public void testNotificationSpecCarriesDedicatedIconPackage() {
NotificationSpec spec = new NotificationSpec();
spec.sourceAppId = "com.whatsapp";
spec.iconPackageId = "com.whatsapp";
assertEquals("com.whatsapp", spec.iconPackageId);
}
@Test
public void testIconRenderingCentersTallDrawable() {
final Bitmap bitmap = Bitmap.createBitmap(40, 80, Bitmap.Config.ARGB_8888);
for (int y = 0; y < 80; y++) {
for (int x = 10; x < 30; x++) {
bitmap.setPixel(x, y, Color.BLACK);
}
}
final byte[] iconBytes = IconHelper.getIconBytesFromBitmap(bitmap, 28, 28);
final int firstFilledColumn = firstFilledColumn(iconBytes, 28, 28);
final int lastFilledColumn = lastFilledColumn(iconBytes, 28, 28);
assertTrue(firstFilledColumn > 0);
assertTrue(lastFilledColumn < 27);
assertTrue(firstFilledColumn >= 8);
assertTrue(lastFilledColumn <= 19);
}
@Test
public void testIconRenderingCropsTransparentPadding() {
final Bitmap bitmap = Bitmap.createBitmap(80, 80, Bitmap.Config.ARGB_8888);
for (int y = 20; y < 60; y++) {
for (int x = 20; x < 60; x++) {
bitmap.setPixel(x, y, Color.BLACK);
}
}
final byte[] iconBytes = IconHelper.getIconBytesFromBitmap(bitmap, 28, 28);
assertTrue(firstFilledColumn(iconBytes, 28, 28) <= 1);
assertTrue(lastFilledColumn(iconBytes, 28, 28) >= 26);
}
@Test
public void testIconRenderingKeepsPaddingWhenCroppingDisabled() {
final Bitmap bitmap = Bitmap.createBitmap(80, 80, Bitmap.Config.ARGB_8888);
for (int y = 20; y < 60; y++) {
for (int x = 20; x < 60; x++) {
bitmap.setPixel(x, y, Color.BLACK);
}
}
final byte[] iconBytes = IconHelper.getIconBytesFromBitmap(bitmap, 28, 28, false);
assertTrue(firstFilledColumn(iconBytes, 28, 28) > 1);
assertTrue(lastFilledColumn(iconBytes, 28, 28) < 26);
}
private boolean columnHasPixels(final byte[] iconBytes, final int column, final int width, final int height) {
final int bytesPerColumn = (height + 7) / 8;
for (int i = 0; i < bytesPerColumn; i++) {
if (iconBytes[column * bytesPerColumn + i] != 0) {
return true;
}
}
return false;
}
private int firstFilledColumn(final byte[] iconBytes, final int width, final int height) {
for (int column = 0; column < width; column++) {
if (columnHasPixels(iconBytes, column, width, height)) {
return column;
}
}
return -1;
}
private int lastFilledColumn(final byte[] iconBytes, final int width, final int height) {
for (int column = width - 1; column >= 0; column--) {
if (columnHasPixels(iconBytes, column, width, height)) {
return column;
}
}
return -1;
}
}