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 @@ + + + diff --git a/app/src/main/res/drawable/battery_android_3_24px.xml b/app/src/main/res/drawable/battery_android_3_24px.xml new file mode 100644 index 00000000..975b4cca --- /dev/null +++ b/app/src/main/res/drawable/battery_android_3_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/battery_android_alert_24px.xml b/app/src/main/res/drawable/battery_android_alert_24px.xml new file mode 100644 index 00000000..1a10f2cb --- /dev/null +++ b/app/src/main/res/drawable/battery_android_alert_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/battery_android_full_24px.xml b/app/src/main/res/drawable/battery_android_full_24px.xml new file mode 100644 index 00000000..2a7a0b05 --- /dev/null +++ b/app/src/main/res/drawable/battery_android_full_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/maps_tag_card.xml b/app/src/main/res/layout/maps_tag_card.xml index 8d7a0ca1..7ac3ab1f 100644 --- a/app/src/main/res/layout/maps_tag_card.xml +++ b/app/src/main/res/layout/maps_tag_card.xml @@ -149,15 +149,35 @@ android:visibility="gone" tools:visibility="visible" /> + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6bda6f93..b08f58d5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -327,7 +327,6 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Klingelt! In der Nähe In der Nähe · Akku %1$s - In der Nähe (%2$s) · Akku %1$s Gerade per Bluetooth vom Tag selbst gelesen, nicht aus iCloud. voll mittel @@ -423,4 +422,5 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es Klingeln fehlgeschlagen: %1$s Kein https: Das API-Token wird unverschlüsselt gesendet. So richtest du das ein + Akku %1$s \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9bd596cf..ce000af1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -333,7 +333,6 @@ You can set this up now, or any time later from Settings. low critical Battery - Nearby (%2$s) · Battery %1$s Over Bluetooth Last seen Signal @@ -423,4 +422,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Battery %1$s \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 86ce02ed..e6843a50 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -334,7 +334,6 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< faible critique Batterie - À proximité (%2$s) · Batterie %1$s Par Bluetooth Dernière détection Signal @@ -423,4 +422,5 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. Échec de la sonnerie : %1$s Pas en https : le jeton d\'API sera envoyé sans chiffrement. Comment le configurer + Batterie %1$s \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 89f8366e..a84ce633 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -333,7 +333,6 @@ 少 危険 バッテリー - 近くにあります(%2$s)· 電池 %1$s Bluetooth 経由 最後の受信 信号強度 @@ -423,4 +422,5 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 鳴らせませんでした: %1$s https ではありません。API トークンは暗号化されずに送信されます。 設定方法 + 電池 %1$s \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 868a1313..a5cef5c0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -333,7 +333,6 @@ 부족 위험 배터리 - 근처에 있음 (%2$s) · 배터리 %1$s 블루투스로 수신 마지막 수신 신호 세기 @@ -423,4 +422,5 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 소리 재생 실패: %1$s https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다. 설정 방법 + 배터리 %1$s \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d2e116d2..06a0a2e5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -333,7 +333,6 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. laag kritiek Batterij - In de buurt (%2$s) · Batterij %1$s Via bluetooth Laatst gehoord Signaal @@ -423,4 +422,5 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he Laten afgaan mislukt: %1$s Geen https: het API-token wordt onversleuteld verstuurd. Zo stel je dit in + Batterij %1$s \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 96a992cb..9b3ea04b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -333,7 +333,6 @@ низкий критический Батарея - Рядом (%2$s) · Батарея %1$s По Bluetooth Последний сигнал Сигнал @@ -423,4 +422,5 @@ Не удалось подать сигнал: %1$s Не https: токен API будет передан без шифрования. Как это настроить + Батарея %1$s \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5f1810a5..da273d38 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -333,7 +333,6 @@ 偏低 极低 电量 - 在附近(%2$s)· 电量%1$s 通过蓝牙 最后收到 信号强度 @@ -423,4 +422,5 @@ 响铃失败:%1$s 不是 https:API 令牌将以未加密方式发送。 如何设置 + 电量%1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9bf37589..40d53e90 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -333,7 +333,6 @@ 偏低 極低 電量 - 在附近(%2$s)· 電量%1$s 透過藍牙 最後收到 訊號強度 @@ -423,4 +422,5 @@ 響鈴失敗:%1$s 不是 https:API 權杖將以未加密方式傳送。 如何設定 + 電量%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f33053d..6257d37b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -361,7 +361,6 @@ You can set this up now, or any time later from Settings. Ringing! Nearby Nearby · Battery %1$s - Nearby (%2$s) · Battery %1$s Read from the tag over Bluetooth just now, not from iCloud. full medium @@ -458,4 +457,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Battery %1$s diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java index 9aba6d4c..34f8569e 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/NearbyTagLabelTest.java @@ -5,6 +5,9 @@ import org.junit.Test; +import java.util.HashSet; +import java.util.Set; + import dev.wander.android.opentagviewer.R; /** A JVM test: {@link NearbyTagLabel} chooses resources without touching a {@code Context}. */ @@ -22,6 +25,18 @@ public void shortBatteryLabelPicksTheMatchingWord() { NearbyTagLabel.shortBatteryLabel(FindMyAdvertisement.BatteryLevel.VERY_LOW)); } + @Test + public void batteryIconGivesEveryLevelItsOwnIcon() { + final Set icons = new HashSet<>(); + for (final FindMyAdvertisement.BatteryLevel level : FindMyAdvertisement.BatteryLevel.values()) { + icons.add(NearbyTagLabel.batteryIcon(level)); + } + // Two levels sharing an icon would read as the same battery on the card. + assertEquals(FindMyAdvertisement.BatteryLevel.values().length, icons.size()); + assertEquals(R.drawable.battery_android_alert_24px, + NearbyTagLabel.batteryIcon(FindMyAdvertisement.BatteryLevel.VERY_LOW)); + } + @Test public void aStrongSignalFillsAllFiveDots() { assertEquals(5, NearbyTagLabel.signalStrengthLevel(-50));