Skip to content

Commit

Permalink
ICU's MessageFormat handles plurals for floats (getQuantityString doe…
Browse files Browse the repository at this point in the history
…s not).

Fixes #1584
  • Loading branch information
dennisguse committed Jun 21, 2023
1 parent e0b84df commit 7c29237
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 35 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Expand Up @@ -69,7 +69,7 @@ android {

buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""

minSdk 21
minSdk 24
targetSdk 34

testInstrumentationRunner "de.dennisguse.opentracks.TestRunner"
Expand Down
Expand Up @@ -3,6 +3,7 @@
import static org.junit.Assert.assertEquals;

import android.content.Context;
import android.icu.text.MessageFormat;
import android.util.Pair;

import androidx.test.core.app.ApplicationProvider;
Expand All @@ -15,8 +16,10 @@

import java.time.Duration;
import java.util.Locale;
import java.util.Map;

import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.TestDataUtil;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.TrackPointIterator;
Expand Down Expand Up @@ -191,6 +194,34 @@ public void getAnnouncement_imperial_speed() {
assertEquals("Total distance 12.4 miles. 1 hour 5 minutes 10 seconds. Average moving speed 11.4 miles per hour.", announcement);
}

@Test
public void getAnnouncement_imperial_speed_1() {
TrackStatistics stats = new TrackStatistics();
stats.setTotalDistance(Distance.ofMile(1.1));
stats.setTotalTime(Duration.ofHours(2).plusMinutes(5).plusSeconds(10));
stats.setMovingTime(Duration.ofHours(1));

// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, UnitSystem.IMPERIAL_FEET, true, null, null).toString();

// then
assertEquals("Total distance 1.1 miles. 1 hour. Average moving speed 1.1 miles per hour.", announcement);
}

@Test
public void getAnnouncement_metric_speed_1() {
TrackStatistics stats = new TrackStatistics();
stats.setTotalDistance(Distance.ofKilometer(1.1));
stats.setTotalTime(Duration.ofHours(2).plusMinutes(5).plusSeconds(10));
stats.setMovingTime(Duration.ofHours(1));

// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, UnitSystem.METRIC, true, null, null).toString();

// then
assertEquals("Total distance 1.1 kilometers. 1 hour. Average moving speed 1.1 kilometers per hour.", announcement);
}

@Test
public void getAnnouncement_withInterval_imperial_speed() {
// given
Expand Down Expand Up @@ -306,4 +337,22 @@ public void getAnnouncement_only_lap_heart_rate() {
// then
assertEquals(" Current heart rate 133 bpm.", announcement);
}

@Test
public void ICUMessageDemo() {
// Android 7's ICU MessageFormat; working
String template = """
{n, plural,
one {1 mile}
other {{n,number,#.#} miles}
}""";

assertEquals("1.1 miles", MessageFormat.format(template, Map.of("n", 1.1)));
assertEquals("1 mile", MessageFormat.format(template, Map.of("n", 1)));
assertEquals("1.1 miles", MessageFormat.format(template, Map.of("n", 1.11)));
assertEquals("1.2 miles", MessageFormat.format(template, Map.of("n", 1.18)));

// Android getQuantityString(); is not working properly; should be `1.1 miles`
assertEquals("1 mile", ApplicationProvider.getApplicationContext().getResources().getQuantityString(R.plurals.voiceDistanceMiles, 1, 1.1));
}
}
Expand Up @@ -9,6 +9,7 @@
import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceTotalDistance;

import android.content.Context;
import android.icu.text.MessageFormat;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.TtsSpan;
Expand All @@ -17,6 +18,7 @@
import androidx.annotation.Nullable;

import java.time.Duration;
import java.util.Map;

import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
Expand Down Expand Up @@ -45,22 +47,22 @@ static Spannable getAnnouncement(Context context, TrackStatistics trackStatistic
switch (unitSystem) {
case METRIC -> {
perUnitStringId = R.string.voice_per_kilometer;
distanceId = R.plurals.voiceDistanceKilometers;
speedId = R.plurals.voiceSpeedKilometersPerHour;
distanceId = R.string.voiceDistanceKilometersPlural;
speedId = R.string.voiceSpeedKilometersPerHourPlural;
unitDistanceTTS = "kilometer";
unitSpeedTTS = "kilometer per hour";
}
case IMPERIAL_FEET -> {
perUnitStringId = R.string.voice_per_mile;
distanceId = R.plurals.voiceDistanceMiles;
speedId = R.plurals.voiceSpeedMilesPerHour;
distanceId = R.string.voiceDistanceMilesPlural;
speedId = R.string.voiceSpeedMilesPerHourPlural;
unitDistanceTTS = "mile";
unitSpeedTTS = "mile per hour";
}
case NAUTICAL_IMPERIAL -> {
perUnitStringId = R.string.voice_per_nautical_mile;
distanceId = R.plurals.voiceDistanceNauticalMiles;
speedId = R.plurals.voiceSpeedMKnots;
distanceId = R.string.voiceDistanceNauticalMilesPlural;
speedId = R.string.voiceSpeedMKnotsPlural;
unitDistanceTTS = "nautical mile";
unitSpeedTTS = "knots";
}
Expand All @@ -73,7 +75,8 @@ static Spannable getAnnouncement(Context context, TrackStatistics trackStatistic
builder.append(context.getString(R.string.total_distance));
// Units should always be english singular for TTS.
// See https://developer.android.com/reference/android/text/style/TtsSpan?hl=en#TYPE_MEASURE
appendDecimalUnit(builder, context.getResources().getQuantityString(distanceId, getQuantityCount(distanceInUnit), distanceInUnit), distanceInUnit, 1, unitDistanceTTS);
String template = context.getResources().getString(distanceId);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", distanceInUnit)), distanceInUnit, 1, unitDistanceTTS);
// Punctuation helps introduce natural pauses in TTS
builder.append(".");
}
Expand All @@ -93,15 +96,17 @@ static Spannable getAnnouncement(Context context, TrackStatistics trackStatistic
double speedInUnit = averageMovingSpeed.to(unitSystem);
builder.append(" ")
.append(context.getString(R.string.speed));
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit), speedInUnit, 1, unitSpeedTTS);
String template = context.getResources().getString(speedId);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", speedInUnit)), speedInUnit, 1, unitSpeedTTS);
builder.append(".");
}
if (shouldVoiceAnnounceLapSpeedPace() && currentDistancePerTime != null) {
double currentDistancePerTimeInUnit = currentDistancePerTime.to(unitSystem);
if (currentDistancePerTimeInUnit > 0) {
builder.append(" ")
.append(context.getString(R.string.lap_speed));
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit), currentDistancePerTimeInUnit, 1, unitSpeedTTS);
String template = context.getResources().getString(speedId);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", currentDistancePerTimeInUnit)), currentDistancePerTimeInUnit, 1, unitSpeedTTS);
builder.append(".");
}
}
Expand Down Expand Up @@ -147,23 +152,22 @@ static Spannable getAnnouncement(Context context, TrackStatistics trackStatistic
return builder;
}

static int getQuantityCount(double d) {
return (int) d;
}

private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) {
int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60);

if (hours > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceHours, hours, hours), hours, 0, "hour");
String template = context.getResources().getString(R.string.voiceHoursPlural);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", hours)), hours, 0, "hour");
}
if (minutes > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceMinutes, minutes, minutes), minutes, 0, "minute");
String template = context.getResources().getString(R.string.voiceMinutesPlural);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", minutes)), minutes, 0, "minute");
}
if (seconds > 0 || duration.isZero()) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceSeconds, seconds, seconds), seconds, 0, "second");
String template = context.getResources().getString(R.string.voiceSecondsPlural);
appendDecimalUnit(builder, MessageFormat.format(template, Map.of("n", seconds)), seconds, 0, "second");
}
}

Expand Down
90 changes: 72 additions & 18 deletions src/main/res/values/strings.xml
Expand Up @@ -554,29 +554,65 @@ limitations under the License.
<string name="value_smallest_recommended">Smallest (recommended)</string>
<!-- Voice -->
<plurals name="voiceHours">
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
<item quantity="one">1 hour [Replaced by VoiceHoursPlural]</item>
<item quantity="other">%1$d hours [Replaced by VoiceHoursPlural]</item>
</plurals>
<plurals name="voiceMinutes">
<item quantity="one">1 minute</item>
<item quantity="other">%1$d minutes</item>
<item quantity="one">1 minute [Replaced by VoiceMinutesPlural]</item>
<item quantity="other">%1$d minutes [Replaced by VoiceMinutesPlural]</item>
</plurals>
<plurals name="voiceSeconds">
<item quantity="one">1 second</item>
<item quantity="other">%1$d seconds</item>
<item quantity="one">1 second [Replaced by voiceSecondsPlural]</item>
<item quantity="other">%1$d seconds [Replaced by voiceSecondsPlural]</item>
</plurals>
<plurals name="voiceSpeedKilometersPerHour">
<item quantity="one">1 kilometer per hour</item>
<item quantity="other">%1$.1f kilometers per hour</item>
<item quantity="one">1 kilometer per hour [Replaced by voiceSpeedKilometersPerHourPlural]</item>
<item quantity="other">%1$.1f kilometers per hour [Replaced by voiceSpeedKilometersPerHourPlural]</item>
</plurals>
<plurals name="voiceSpeedMilesPerHour">
<item quantity="one">1 mile per hour</item>
<item quantity="other">%1$.1f miles per hour</item>
<item quantity="one">1 mile per hour [Replaced by voiceSpeedMilesPerHourPlural]</item>
<item quantity="other">%1$.1f miles per hour [Replaced by voiceSpeedMilesPerHourPlural]</item>
</plurals>
<plurals name="voiceSpeedMKnots">
<item quantity="one">1 knot</item>
<item quantity="other">%1$.1f knots</item>
<item quantity="one">1 knot [Replaced by voiceSpeedMKnotsPlural]</item>
<item quantity="other">%1$.1f knots [Replaced by voiceSpeedMKnotsPlural]</item>
</plurals>
<string name="voiceHoursPlural">
{n, plural,
=1 {1 hour}
other {{n,number} hours}
}
</string>
<string name="voiceMinutesPlural">
{n, plural,
=1 {1 minute}
other {{n,number} minutes}
}
</string>
<string name="voiceSecondsPlural">
{n, plural,
=1 {1 second}
other {{n,number} seconds}
}
</string>
<string name="voiceSpeedKilometersPerHourPlural">
{n, plural,
=1 {1 kilometer per hour}
other {{n,number,#.0} kilometers per hour}
}
</string>
<string name="voiceSpeedMilesPerHourPlural">
{n, plural,
=1 {1 mile per hour}
other {{n,number,#.0} miles per hour}
}
</string>
<string name="voiceSpeedMKnotsPlural">
{n, plural,
=1 {1 knot}
other {{n,number,#.0} knots}
}
</string>
<string name="voice_on_device_speaker_title">Use the device\'s speaker</string>
<string name="voice_per_kilometer">per kilometer</string>
<string name="voice_per_mile">per mile</string>
Expand All @@ -587,17 +623,35 @@ limitations under the License.
<string name="speed">Average moving speed</string>
<string name="total_distance">Total distance</string>
<plurals name="voiceDistanceKilometers">
<item quantity="one">1 kilometer</item>
<item quantity="other">%1$.1f kilometers</item>
<item quantity="one">1 kilometer [Replaced by voiceDistanceKilometersPlural]</item>
<item quantity="other">%1$.1f kilometers [Replaced by voiceDistanceKilometersPlural]</item>
</plurals>
<plurals name="voiceDistanceMiles">
<item quantity="one">1 mile</item>
<item quantity="other">%1$.1f miles</item>
<item quantity="one">1 mile [Replaced by voiceDistanceMilesPlural]</item>
<item quantity="other">%1$.1f miles [Replaced by voiceDistanceMilesPlural]</item>
</plurals>
<plurals name="voiceDistanceNauticalMiles">
<item quantity="one">1 nautical mile</item>
<item quantity="other">%1$.1f nautical miles</item>
<item quantity="one">1 nautical mile [Replaced by voiceDistanceNauticalMilesPlural]</item>
<item quantity="other">%1$.1f nautical miles [Replaced by voiceDistanceNauticalMilesPlural]</item>
</plurals>
<string name="voiceDistanceKilometersPlural">
{n, plural,
=1 {1 kilometer}
other {{n,number,#.0} kilometers}
}
</string>
<string name="voiceDistanceMilesPlural">
{n, plural,
=1 {1 mile}
other {{n,number,#.0} miles}
}
</string>
<string name="voiceDistanceNauticalMilesPlural">
{n, plural,
=1 {1 nautical mile}
other {{n,number,#.0} nautical miles}
}
</string>
<string name="average_heart_rate">Average heart rate</string>
<string name="current_heart_rate">Current heart rate</string>
<!-- Waypoint Type -->
Expand Down

0 comments on commit 7c29237

Please sign in to comment.