Plural quantities via ICU.
This commit is contained in:
Dennis Guse
2023-06-21 19:25:17 +02:00
committed by GitHub
4 changed files with 138 additions and 35 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ android {
buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\"" buildConfigField "String", "VERSION_NAME_FULL", "\"${getVersionName()}\""
minSdk 21 minSdk 24
targetSdk 34 targetSdk 34
testInstrumentationRunner "de.dennisguse.opentracks.TestRunner" testInstrumentationRunner "de.dennisguse.opentracks.TestRunner"
@@ -3,6 +3,7 @@ package de.dennisguse.opentracks.services.announcement;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import android.content.Context; import android.content.Context;
import android.icu.text.MessageFormat;
import android.util.Pair; import android.util.Pair;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@@ -15,6 +16,7 @@ import org.junit.runner.RunWith;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import de.dennisguse.opentracks.LocaleRule; import de.dennisguse.opentracks.LocaleRule;
import de.dennisguse.opentracks.content.data.TestDataUtil; import de.dennisguse.opentracks.content.data.TestDataUtil;
@@ -191,6 +193,34 @@ public class VoiceAnnouncementUtilsTest {
assertEquals("Total distance 12.4 miles. 1 hour 5 minutes 10 seconds. Average moving speed 11.4 miles per hour.", announcement); 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 @Test
public void getAnnouncement_withInterval_imperial_speed() { public void getAnnouncement_withInterval_imperial_speed() {
// given // given
@@ -306,4 +336,19 @@ public class VoiceAnnouncementUtilsTest {
// then // then
assertEquals(" Current heart rate 133 bpm.", announcement); 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)));
}
} }
@@ -9,6 +9,7 @@ import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnno
import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceTotalDistance; import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceTotalDistance;
import android.content.Context; import android.content.Context;
import android.icu.text.MessageFormat;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.style.TtsSpan; import android.text.style.TtsSpan;
@@ -17,6 +18,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance; import de.dennisguse.opentracks.data.models.Distance;
@@ -45,22 +47,22 @@ class VoiceAnnouncementUtils {
switch (unitSystem) { switch (unitSystem) {
case METRIC -> { case METRIC -> {
perUnitStringId = R.string.voice_per_kilometer; perUnitStringId = R.string.voice_per_kilometer;
distanceId = R.plurals.voiceDistanceKilometers; distanceId = R.string.voiceDistanceKilometersPlural;
speedId = R.plurals.voiceSpeedKilometersPerHour; speedId = R.string.voiceSpeedKilometersPerHourPlural;
unitDistanceTTS = "kilometer"; unitDistanceTTS = "kilometer";
unitSpeedTTS = "kilometer per hour"; unitSpeedTTS = "kilometer per hour";
} }
case IMPERIAL_FEET -> { case IMPERIAL_FEET -> {
perUnitStringId = R.string.voice_per_mile; perUnitStringId = R.string.voice_per_mile;
distanceId = R.plurals.voiceDistanceMiles; distanceId = R.string.voiceDistanceMilesPlural;
speedId = R.plurals.voiceSpeedMilesPerHour; speedId = R.string.voiceSpeedMilesPerHourPlural;
unitDistanceTTS = "mile"; unitDistanceTTS = "mile";
unitSpeedTTS = "mile per hour"; unitSpeedTTS = "mile per hour";
} }
case NAUTICAL_IMPERIAL -> { case NAUTICAL_IMPERIAL -> {
perUnitStringId = R.string.voice_per_nautical_mile; perUnitStringId = R.string.voice_per_nautical_mile;
distanceId = R.plurals.voiceDistanceNauticalMiles; distanceId = R.string.voiceDistanceNauticalMilesPlural;
speedId = R.plurals.voiceSpeedMKnots; speedId = R.string.voiceSpeedMKnotsPlural;
unitDistanceTTS = "nautical mile"; unitDistanceTTS = "nautical mile";
unitSpeedTTS = "knots"; unitSpeedTTS = "knots";
} }
@@ -73,7 +75,8 @@ class VoiceAnnouncementUtils {
builder.append(context.getString(R.string.total_distance)); builder.append(context.getString(R.string.total_distance));
// Units should always be english singular for TTS. // Units should always be english singular for TTS.
// See https://developer.android.com/reference/android/text/style/TtsSpan?hl=en#TYPE_MEASURE // 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 // Punctuation helps introduce natural pauses in TTS
builder.append("."); builder.append(".");
} }
@@ -93,7 +96,8 @@ class VoiceAnnouncementUtils {
double speedInUnit = averageMovingSpeed.to(unitSystem); double speedInUnit = averageMovingSpeed.to(unitSystem);
builder.append(" ") builder.append(" ")
.append(context.getString(R.string.speed)); .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("."); builder.append(".");
} }
if (shouldVoiceAnnounceLapSpeedPace() && currentDistancePerTime != null) { if (shouldVoiceAnnounceLapSpeedPace() && currentDistancePerTime != null) {
@@ -101,7 +105,8 @@ class VoiceAnnouncementUtils {
if (currentDistancePerTimeInUnit > 0) { if (currentDistancePerTimeInUnit > 0) {
builder.append(" ") builder.append(" ")
.append(context.getString(R.string.lap_speed)); .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("."); builder.append(".");
} }
} }
@@ -147,23 +152,22 @@ class VoiceAnnouncementUtils {
return builder; return builder;
} }
static int getQuantityCount(double d) {
return (int) d;
}
private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) { private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) {
int hours = (int) (duration.toHours()); int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60); int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60); int seconds = (int) (duration.getSeconds() % 60);
if (hours > 0) { 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) { 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()) { 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");
} }
} }
+72 -18
View File
@@ -554,29 +554,65 @@ limitations under the License.
<string name="value_smallest_recommended">Smallest (recommended)</string> <string name="value_smallest_recommended">Smallest (recommended)</string>
<!-- Voice --> <!-- Voice -->
<plurals name="voiceHours"> <plurals name="voiceHours">
<item quantity="one">1 hour</item> <item quantity="one">1 hour [Replaced by VoiceHoursPlural]</item>
<item quantity="other">%1$d hours</item> <item quantity="other">%1$d hours [Replaced by VoiceHoursPlural]</item>
</plurals> </plurals>
<plurals name="voiceMinutes"> <plurals name="voiceMinutes">
<item quantity="one">1 minute</item> <item quantity="one">1 minute [Replaced by VoiceMinutesPlural]</item>
<item quantity="other">%1$d minutes</item> <item quantity="other">%1$d minutes [Replaced by VoiceMinutesPlural]</item>
</plurals> </plurals>
<plurals name="voiceSeconds"> <plurals name="voiceSeconds">
<item quantity="one">1 second</item> <item quantity="one">1 second [Replaced by voiceSecondsPlural]</item>
<item quantity="other">%1$d seconds</item> <item quantity="other">%1$d seconds [Replaced by voiceSecondsPlural]</item>
</plurals> </plurals>
<plurals name="voiceSpeedKilometersPerHour"> <plurals name="voiceSpeedKilometersPerHour">
<item quantity="one">1 kilometer per hour</item> <item quantity="one">1 kilometer per hour [Replaced by voiceSpeedKilometersPerHourPlural]</item>
<item quantity="other">%1$.1f kilometers per hour</item> <item quantity="other">%1$.1f kilometers per hour [Replaced by voiceSpeedKilometersPerHourPlural]</item>
</plurals> </plurals>
<plurals name="voiceSpeedMilesPerHour"> <plurals name="voiceSpeedMilesPerHour">
<item quantity="one">1 mile per hour</item> <item quantity="one">1 mile per hour [Replaced by voiceSpeedMilesPerHourPlural]</item>
<item quantity="other">%1$.1f miles per hour</item> <item quantity="other">%1$.1f miles per hour [Replaced by voiceSpeedMilesPerHourPlural]</item>
</plurals> </plurals>
<plurals name="voiceSpeedMKnots"> <plurals name="voiceSpeedMKnots">
<item quantity="one">1 knot</item> <item quantity="one">1 knot [Replaced by voiceSpeedMKnotsPlural]</item>
<item quantity="other">%1$.1f knots</item> <item quantity="other">%1$.1f knots [Replaced by voiceSpeedMKnotsPlural]</item>
</plurals> </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_on_device_speaker_title">Use the device\'s speaker</string>
<string name="voice_per_kilometer">per kilometer</string> <string name="voice_per_kilometer">per kilometer</string>
<string name="voice_per_mile">per mile</string> <string name="voice_per_mile">per mile</string>
@@ -587,17 +623,35 @@ limitations under the License.
<string name="speed">Average moving speed</string> <string name="speed">Average moving speed</string>
<string name="total_distance">Total distance</string> <string name="total_distance">Total distance</string>
<plurals name="voiceDistanceKilometers"> <plurals name="voiceDistanceKilometers">
<item quantity="one">1 kilometer</item> <item quantity="one">1 kilometer [Replaced by voiceDistanceKilometersPlural]</item>
<item quantity="other">%1$.1f kilometers</item> <item quantity="other">%1$.1f kilometers [Replaced by voiceDistanceKilometersPlural]</item>
</plurals> </plurals>
<plurals name="voiceDistanceMiles"> <plurals name="voiceDistanceMiles">
<item quantity="one">1 mile</item> <item quantity="one">1 mile [Replaced by voiceDistanceMilesPlural]</item>
<item quantity="other">%1$.1f miles</item> <item quantity="other">%1$.1f miles [Replaced by voiceDistanceMilesPlural]</item>
</plurals> </plurals>
<plurals name="voiceDistanceNauticalMiles"> <plurals name="voiceDistanceNauticalMiles">
<item quantity="one">1 nautical mile</item> <item quantity="one">1 nautical mile [Replaced by voiceDistanceNauticalMilesPlural]</item>
<item quantity="other">%1$.1f nautical miles</item> <item quantity="other">%1$.1f nautical miles [Replaced by voiceDistanceNauticalMilesPlural]</item>
</plurals> </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="average_heart_rate">Average heart rate</string>
<string name="current_heart_rate">Current heart rate</string> <string name="current_heart_rate">Current heart rate</string>
<!-- Waypoint Type --> <!-- Waypoint Type -->