diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardBatteryIconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardBatteryIconTest.java new file mode 100644 index 00000000..d3424818 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardBatteryIconTest.java @@ -0,0 +1,304 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Locale; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement.BatteryLevel; +import dev.wander.android.opentagviewer.ble.NearbyTagLabel; +import java.nio.charset.StandardCharsets; + +/** + * The battery icon on a map tag card's "Nearby" line (#228), which replaced the word. + * + *
Inflates the real card against the app theme, light and dark, and applies + * {@link NearbyBatteryIcon} the way {@code MapsActivity#showNearbyStatusOn} does. No activity, + * no map and no Apple session. Writes before/after screenshots for every level and theme to the + * directory AGP passes as {@code additionalTestOutputDir}; see the device-screenshots skill. + */ +@RunWith(AndroidJUnit4.class) +public class TagCardBatteryIconTest { + + private static final String TAG = TagCardBatteryIconTest.class.getSimpleName(); + + /** Matches TagCardLayoutTest: roughly a card's width on a phone. */ + private static final int CARD_WIDTH_PX = 1000; + + /** WCAG 2.1 minimum for a non-text element against what is behind it. */ + private static final double MINIMUM_NON_TEXT_CONTRAST = 3.0d; + + private static final String NEARBY_LINE = "Nearby (●●●○○)"; + + private static File outputDir; + + @BeforeClass + public static void resolveOutputDir() { + final String fromAgp = InstrumentationRegistry.getArguments() + .getString("additionalTestOutputDir"); + outputDir = fromAgp != null + ? new File(fromAgp) + : getInstrumentation().getTargetContext().getExternalFilesDir("tag-card-shots"); + if (outputDir != null && !outputDir.exists()) { + //noinspection ResultOfMethodCallIgnored + outputDir.mkdirs(); + } + } + + @Test + public void everyLevelShowsAnIconAndKeepsTheWordForScreenReaders() { + for (final BatteryLevel level : BatteryLevel.values()) { + final FrameLayout card = this.inflateCard(lightTheme(), NEARBY_LINE, level); + final ImageView icon = card.findViewById(R.id.device_nearby_battery); + + assertEquals(level + " icon not shown", View.VISIBLE, icon.getVisibility()); + assertNotNull(level + " icon has no drawable", icon.getDrawable()); + assertTrue(level + " drawable has no size", icon.getDrawable().getIntrinsicWidth() > 0); + + final String word = card.getContext().getString(NearbyTagLabel.shortBatteryLabel(level)); + assertEquals("Battery " + word, String.valueOf(icon.getContentDescription())); + } + } + + /** The English wording, spelled out, so the check is not only resources agreeing with themselves. */ + @Test + public void theMediumIconIsReadAsBatteryMedium() { + final FrameLayout card = this.inflateCard(lightTheme(), NEARBY_LINE, BatteryLevel.MEDIUM); + final ImageView icon = card.findViewById(R.id.device_nearby_battery); + assertEquals("Battery medium", String.valueOf(icon.getContentDescription())); + } + + /** + * Each level draws something, in both themes, and no two levels draw the same picture. + * + *
Rendered from the inflated view rather than from the drawable resource, because a vector + * loaded without the card's theme draws as nothing while a test that supplies a theme stays + * green - see AGENTS.md rule 12. + */ + @Test + public void everyLevelDrawsADifferentVisibleIconInBothThemes() { + for (final Context theme : new Context[]{lightTheme(), darkTheme()}) { + final Bitmap[] drawn = new Bitmap[BatteryLevel.values().length]; + for (final BatteryLevel level : BatteryLevel.values()) { + final FrameLayout card = this.inflateCard(theme, NEARBY_LINE, level); + final Bitmap bitmap = this.draw(card.findViewById(R.id.device_nearby_battery)); + assertTrue(level + " icon drew no pixels", opaquePixels(bitmap) > 0); + drawn[level.ordinal()] = bitmap; + } + for (int a = 0; a < drawn.length; a++) { + for (int b = a + 1; b < drawn.length; b++) { + assertFalse(BatteryLevel.values()[a] + " and " + BatteryLevel.values()[b] + + " draw the same icon", drawn[a].sameAs(drawn[b])); + } + } + } + } + + @Test + public void aSightingWithoutBatteryFlagsHidesTheIcon() { + final FrameLayout card = this.inflateCard(lightTheme(), NEARBY_LINE, null); + final ImageView icon = card.findViewById(R.id.device_nearby_battery); + assertEquals(View.GONE, icon.getVisibility()); + assertNull(icon.getContentDescription()); + } + + /** The icon measures to its 16dp, and does not make the card taller than the plain line. */ + @Test + public void theIconMeasuresAndDoesNotGrowTheCard() { + final FrameLayout baseline = this.inflateCard(lightTheme(), "Last Updated: 2 minutes ago", null); + final FrameLayout withIcon = this.inflateCard(lightTheme(), NEARBY_LINE, BatteryLevel.VERY_LOW); + final ImageView icon = withIcon.findViewById(R.id.device_nearby_battery); + + final int expectedPx = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + 16, withIcon.getResources().getDisplayMetrics())); + assertEquals(expectedPx, icon.getMeasuredWidth()); + assertEquals(expectedPx, icon.getMeasuredHeight()); + assertEquals("the icon grew the card", baseline.getMeasuredHeight(), withIcon.getMeasuredHeight()); + } + + /** + * The tint against the card background clears 3:1 in both themes. + * + *
Resolved colours, not attribute names: a selected map pin here once shipped at 1.23:1. + * The figures are also written next to the screenshots for the pull request. + */ + @Test + public void theTintClearsNonTextContrastInBothThemes() { + final StringBuilder figures = new StringBuilder(); + for (final Context theme : new Context[]{lightTheme(), darkTheme()}) { + final int tint = resolve(theme, com.google.android.material.R.attr.colorOutline); + final int background = resolve(theme, android.R.attr.colorBackground); + final double ratio = contrastRatio(tint, background); + final String name = isNight(theme) ? "dark" : "light"; + figures.append(String.format(Locale.ROOT, "%s: tint #%06X on #%06X = %.2f:1%n", + name, tint & 0xFFFFFF, background & 0xFFFFFF, ratio)); + assertTrue(String.format(Locale.ROOT, "%s battery icon contrast %.2f:1 is under 3:1", + name, ratio), ratio >= MINIMUM_NON_TEXT_CONTRAST); + } + Log.i(TAG, figures.toString()); + this.writeText("tag_card_battery_contrast.txt", figures.toString()); + } + + /** Not an assertion: the before/after images for the pull request. */ + @Test + public void writeBeforeAndAfterScreenshots() { + for (final Context theme : new Context[]{lightTheme(), darkTheme()}) { + final String variant = isNight(theme) ? "dark" : "light"; + for (final BatteryLevel level : BatteryLevel.values()) { + final String word = theme.getString(NearbyTagLabel.shortBatteryLabel(level)); + final String name = level.name().toLowerCase(Locale.ROOT); + // What the line said before #228, through the same card. + this.write("tag_card_before_" + name + "-" + variant + ".png", + this.draw(this.inflateCard(theme, NEARBY_LINE + " · Battery " + word, null))); + this.write("tag_card_after_" + name + "-" + variant + ".png", + this.draw(this.inflateCard(theme, NEARBY_LINE, level))); + } + } + } + + // --- helpers --------------------------------------------------------------------------- + + private FrameLayout inflateCard(final Context theme, final String line, final BatteryLevel level) { + final FrameLayout[] result = new FrameLayout[1]; + getInstrumentation().runOnMainSync(() -> { + final FrameLayout card = (FrameLayout) LayoutInflater.from(theme) + .inflate(R.layout.maps_tag_card, null); + card.setLayoutParams(new ViewGroup.LayoutParams( + CARD_WIDTH_PX, ViewGroup.LayoutParams.WRAP_CONTENT)); + ((TextView) card.findViewById(R.id.device_name)).setText("Keys"); + ((TextView) card.findViewById(R.id.device_location)).setText("Amsterdam, Netherlands"); + ((TextView) card.findViewById(R.id.device_last_update)).setText(line); + NearbyBatteryIcon.show(card.findViewById(R.id.device_nearby_battery), level); + card.measure( + View.MeasureSpec.makeMeasureSpec(CARD_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + card.layout(0, 0, card.getMeasuredWidth(), card.getMeasuredHeight()); + result[0] = card; + }); + return result[0]; + } + + private Bitmap draw(final View view) { + final Bitmap[] result = new Bitmap[1]; + getInstrumentation().runOnMainSync(() -> { + final Bitmap bitmap = Bitmap.createBitmap(Math.max(1, view.getWidth()), + Math.max(1, view.getHeight()), Bitmap.Config.ARGB_8888); + view.draw(new Canvas(bitmap)); + result[0] = bitmap; + }); + return result[0]; + } + + private static int opaquePixels(final Bitmap bitmap) { + int count = 0; + for (int y = 0; y < bitmap.getHeight(); y++) { + for (int x = 0; x < bitmap.getWidth(); x++) { + if (Color.alpha(bitmap.getPixel(x, y)) > 0) { + count++; + } + } + } + return count; + } + + private static Context lightTheme() { + return new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + } + + private static Context darkTheme() { + final Context base = getInstrumentation().getTargetContext(); + final Configuration night = new Configuration(base.getResources().getConfiguration()); + night.uiMode = (night.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | Configuration.UI_MODE_NIGHT_YES; + return new ContextThemeWrapper( + base.createConfigurationContext(night), R.style.Theme_OpenTagViewer); + } + + private static boolean isNight(final Context context) { + return (context.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + private static int resolve(final Context context, final int attr) { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attr, value, true); + return value.data; + } + + /** WCAG 2.1 relative luminance. */ + private static double relativeLuminance(final int color) { + final double[] channel = { + Color.red(color) / 255d, + Color.green(color) / 255d, + Color.blue(color) / 255d, + }; + for (int i = 0; i < channel.length; i++) { + channel[i] = channel[i] <= 0.04045d + ? channel[i] / 12.92d + : Math.pow((channel[i] + 0.055d) / 1.055d, 2.4d); + } + return 0.2126d * channel[0] + 0.7152d * channel[1] + 0.0722d * channel[2]; + } + + private static double contrastRatio(final int a, final int b) { + final double la = relativeLuminance(a); + final double lb = relativeLuminance(b); + return (Math.max(la, lb) + 0.05d) / (Math.min(la, lb) + 0.05d); + } + + private void write(final String fileName, final Bitmap bitmap) { + if (outputDir == null) { + Log.w(TAG, "No output directory; skipping " + fileName); + return; + } + final File target = new File(outputDir, fileName); + try (FileOutputStream out = new FileOutputStream(target)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } catch (IOException e) { + throw new AssertionError("could not write " + target, e); + } + } + + private void writeText(final String fileName, final String text) { + if (outputDir == null) { + return; + } + final File target = new File(outputDir, fileName); + try (FileOutputStream out = new FileOutputStream(target)) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new AssertionError("could not write " + target, e); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java index c605b1a6..2b6f6a67 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; /** * Layout tests for the tag cards along the bottom of the map. @@ -273,8 +274,8 @@ public void aLongDeviceNameDoesNotMakeItsCardTaller() { } /** - * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal and the - * longest battery word - must not wrap to a second line and grow the row. Built directly + * {@code MapsActivity.showNearbyStatusOn}'s longest realistic line - full signal, with the + * battery icon showing - must not wrap to a second line and grow the row. Built directly * rather than through {@link #measureHeights}, which always writes a fixed string to this * field. */ @@ -299,11 +300,12 @@ public void theLongestNearbyStatusLineDoesNotMakeItsCardTaller() { ((TextView) baseline.findViewById(R.id.device_last_update)) .setText("Last Updated: 2 minutes ago"); - // "critical" (English) / "kritisch" (German) is the longest battery word; five - // filled dots is the longest signal reading; three digits covers up to the 30 - // second freshness window in NearbyTagSightings with room to spare. + // Five filled dots is the longest signal reading. The battery is an icon now + // (NearbyBatteryIcon), so the icon has to fit on the line beside it, not a word. ((TextView) withNearbyStatus.findViewById(R.id.device_last_update)) - .setText("Nearby (●●●●●) · Battery critical"); + .setText("Nearby (●●●●●)"); + NearbyBatteryIcon.show(withNearbyStatus.findViewById(R.id.device_nearby_battery), + FindMyAdvertisement.BatteryLevel.VERY_LOW); for (final FrameLayout card : new FrameLayout[]{baseline, withNearbyStatus}) { card.measure( diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index 95df86fc..b088536c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -63,6 +63,7 @@ import dev.wander.android.opentagviewer.ui.maps.MapMarker; import dev.wander.android.opentagviewer.ui.maps.MapPolyline; import dev.wander.android.opentagviewer.ui.maps.MarkerPalette; +import dev.wander.android.opentagviewer.ui.maps.NearbyBatteryIcon; import com.google.android.libraries.places.api.Places; import com.google.android.material.color.MaterialColors; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -901,13 +902,13 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) { private void showNearbyStatusOn( final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) { final TextView line = card.findViewById(R.id.device_last_update); - // Google trackers encrypt their battery flags, so their sightings carry none - line.setText(sighting.getBatteryLevel() == null - ? this.getString(R.string.nearby_now_with_signal, - NearbyTagLabel.signalStrengthBars(sighting.getRssi())) - : this.getString(R.string.nearby_now_with_battery_and_signal, - this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())), - NearbyTagLabel.signalStrengthBars(sighting.getRssi()))); + line.setText(this.getString(R.string.nearby_now_with_signal, + NearbyTagLabel.signalStrengthBars(sighting.getRssi()))); + // An icon rather than "· Battery medium": the row also carries the name's line, the + // signal dots and the pulse. Hidden for Google trackers, whose battery flags are + // encrypted, so their sightings carry none. + NearbyBatteryIcon.show(card.findViewById(R.id.device_nearby_battery), + sighting.getBatteryLevel()); // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan // callback, before the clock the caller reads has ticked past it. @@ -2707,8 +2708,10 @@ private synchronized void updateBeaconCards() { : null; final String updated = this.getString(R.string.last_updated_x, timeAgo); deviceLastUpdate.setText(source == null ? updated : updated + " · " + source); - // Nothing live to pulse for once the sighting has aged out. + // Nothing live to pulse for once the sighting has aged out, and no battery + // reading that is current either. ((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE); + NearbyBatteryIcon.show(v.findViewById(R.id.device_nearby_battery), null); } // **Put an existing card where it now belongs.** Cards are created once and reused, diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java index 96d79253..22c40dfd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/NearbyTagLabel.java @@ -1,5 +1,6 @@ package dev.wander.android.opentagviewer.ble; +import androidx.annotation.DrawableRes; import androidx.annotation.StringRes; import dev.wander.android.opentagviewer.R; @@ -34,6 +35,25 @@ public static int shortBatteryLabel(final FindMyAdvertisement.BatteryLevel level } } + /** + * The battery icon for a tag card, in place of the {@link #shortBatteryLabel} word. + * + *
Material Symbols' horizontal {@code battery_android_*} set, so it sits on a text line + * instead of standing a full line tall. The bar counts follow the {@code battery_level_*} + * ranges: full, about half, a sliver, and the alert glyph for critical, so the level that + * needs attention is the one that does not look like the others. + */ + @DrawableRes + public static int batteryIcon(final FindMyAdvertisement.BatteryLevel level) { + switch (level) { + case MEDIUM: return R.drawable.battery_android_3_24px; + case LOW: return R.drawable.battery_android_1_24px; + case VERY_LOW: return R.drawable.battery_android_alert_24px; + case FULL: + default: return R.drawable.battery_android_full_24px; + } + } + /** Filled dot, for {@link #signalStrengthBars}. */ private static final char BAR_FILLED = '●'; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/NearbyBatteryIcon.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/NearbyBatteryIcon.java new file mode 100644 index 00000000..8bcdeceb --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/NearbyBatteryIcon.java @@ -0,0 +1,46 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.widget.ImageView; + +import androidx.annotation.Nullable; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.ble.FindMyAdvertisement; +import dev.wander.android.opentagviewer.ble.NearbyTagLabel; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Puts a sighting's battery level on a tag card's status line as an icon. + * + *
Split out of {@code MapsActivity#showNearbyStatusOn} so a layout test can drive it on an + * inflated card without starting the activity, which needs Maps and an Apple session. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class NearbyBatteryIcon { + + /** + * Shows the icon for {@code level}, or hides it when there is none to show. + * + *
{@code setImageResource} loads through the view's own context, so the vector's
+ * {@code ?attr/colorControlNormal} resolves against the card's theme. The content description
+ * carries the word the icon replaced, so a screen reader still says "Battery medium".
+ *
+ * @param level null for a sighting without battery flags; Google trackers encrypt theirs.
+ */
+ public static void show(final ImageView icon, @Nullable final FindMyAdvertisement.BatteryLevel level) {
+ if (level == null) {
+ icon.setVisibility(GONE);
+ icon.setContentDescription(null);
+ return;
+ }
+ icon.setImageResource(NearbyTagLabel.batteryIcon(level));
+ icon.setContentDescription(icon.getContext().getString(
+ R.string.battery_icon_description,
+ icon.getContext().getString(NearbyTagLabel.shortBatteryLabel(level))));
+ icon.setVisibility(VISIBLE);
+ }
+}
diff --git a/app/src/main/res/drawable/battery_android_1_24px.xml b/app/src/main/res/drawable/battery_android_1_24px.xml
new file mode 100644
index 00000000..993e0b5a
--- /dev/null
+++ b/app/src/main/res/drawable/battery_android_1_24px.xml
@@ -0,0 +1,10 @@
+