Skip to content

Commit e1068e7

Browse files
committed
:core:keys remove JVM dependency
1 parent a42d823 commit e1068e7

8 files changed

Lines changed: 186 additions & 17 deletions

File tree

core/keys/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import kotlin.math.min
33
plugins {
44
alias(libs.plugins.android.library)
55
id("android-module-dependencies")
6+
// Test only. composeKey() builds preference key names by hand instead of using String.format,
7+
// so it needs tests that pin the exact text it produces.
8+
id("test-module-dependencies")
69
}
710

811
android {

core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanComposedNonPreferenceKey.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.aaps.core.keys.interfaces
33
/**
44
* Preference key where key is a format string see [String::format]
55
*
6-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
6+
* Final key is composed as key + format, with %d and %s replaced by the arguments
77
*/
88
interface BooleanComposedNonPreferenceKey : NonPreferenceKey, ComposedKey {
99

1010
/**
1111
* Key is used as prefix for recognizing the preference
1212
*
13-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
13+
* Final key is composed as key + format, with %d and %s replaced by the arguments
1414
*/
1515
override val key: String
1616

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,72 @@
11
package app.aaps.core.keys.interfaces
22

3-
import java.util.Locale
4-
53
interface ComposedKey {
64

75
/**
86
* Key is used as prefix for recognizing the preference
97
*
10-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
8+
* Final key is composed as key + format, with the placeholders replaced by the arguments
119
*/
1210
val key: String
1311

1412
/**
15-
* String used to format vararg
13+
* String used to format vararg.
14+
*
15+
* Only `%d` (whole number), `%s` (any value) and `%%` (a plain percent sign) are allowed.
1616
*/
1717
val format: String
1818

1919
/**
20-
* Compose final key from arguments
20+
* Compose final key from arguments.
21+
*
22+
* This does not use `String.format` on purpose, for two reasons:
23+
*
24+
* 1. The result becomes a preference key name. `String.format` follows a locale, and a locale
25+
* with its own digits (Arabic, Nepali, Burmese, ...) would write `%d` in that script. The key
26+
* would then be different and the stored setting would be lost. The old code passed
27+
* `Locale.ENGLISH` to avoid that; building the text by hand removes the risk completely.
28+
* 2. `String.format` only exists on the JVM, so this file can be shared with other platforms.
29+
*
30+
* @throws IllegalArgumentException if the format holds anything but `%d`, `%s` and `%%`, if the
31+
* value for a `%d` is not a whole number, or if the number of arguments does not match.
2132
*/
22-
fun composeKey(vararg arguments: Any): String = String.format(Locale.ENGLISH, key + format, *arguments)
23-
}
33+
fun composeKey(vararg arguments: Any): String {
34+
val template = key + format
35+
val composed = StringBuilder(template.length + arguments.size * 4)
36+
var used = 0
37+
var i = 0
38+
while (i < template.length) {
39+
val char = template[i]
40+
if (char != '%') {
41+
composed.append(char)
42+
i++
43+
continue
44+
}
45+
require(i + 1 < template.length) { "Format of '$key' ends with a single %" }
46+
when (val type = template[i + 1]) {
47+
'%' -> composed.append('%')
48+
49+
's' -> {
50+
require(used < arguments.size) { "Format of '$key' needs more than the ${arguments.size} given arguments" }
51+
composed.append(arguments[used])
52+
used++
53+
}
54+
55+
'd' -> {
56+
require(used < arguments.size) { "Format of '$key' needs more than the ${arguments.size} given arguments" }
57+
val value = arguments[used]
58+
require(value is Int || value is Long || value is Short || value is Byte) {
59+
"Format of '$key' uses %d, so argument ${used + 1} must be a whole number, but it is ${value::class.simpleName}"
60+
}
61+
composed.append(value)
62+
used++
63+
}
64+
65+
else -> throw IllegalArgumentException("Format of '$key' uses %$type, only %d, %s and %% are supported")
66+
}
67+
i += 2
68+
}
69+
require(used == arguments.size) { "Format of '$key' uses $used arguments, but ${arguments.size} were given" }
70+
return composed.toString()
71+
}
72+
}

core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoubleComposedNonPreferenceKey.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.aaps.core.keys.interfaces
33
/**
44
* Preference key where key is a format string see [String::format]
55
*
6-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
6+
* Final key is composed as key + format, with %d and %s replaced by the arguments
77
*/
88
interface DoubleComposedNonPreferenceKey : NonPreferenceKey, ComposedKey {
99

1010
/**
1111
* Key is used as prefix for recognizing the preference
1212
*
13-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
13+
* Final key is composed as key + format, with %d and %s replaced by the arguments
1414
*/
1515
override val key: String
1616

core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntComposedNonPreferenceKey.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.aaps.core.keys.interfaces
33
/**
44
* Preference key where key is a format string see [String::format]
55
*
6-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
6+
* Final key is composed as key + format, with %d and %s replaced by the arguments
77
*/
88
interface IntComposedNonPreferenceKey : NonPreferenceKey, ComposedKey {
99

1010
/**
1111
* Key is used as prefix for recognizing the preference
1212
*
13-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
13+
* Final key is composed as key + format, with %d and %s replaced by the arguments
1414
*/
1515
override val key: String
1616

core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongComposedNonPreferenceKey.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.aaps.core.keys.interfaces
33
/**
44
* Preference key where key is a format string see [String::format]
55
*
6-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
6+
* Final key is composed as key + format, with %d and %s replaced by the arguments
77
*/
88
interface LongComposedNonPreferenceKey : NonPreferenceKey, ComposedKey {
99

1010
/**
1111
* Key is used as prefix for recognizing the preference
1212
*
13-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
13+
* Final key is composed as key + format, with %d and %s replaced by the arguments
1414
*/
1515
override val key: String
1616

core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringComposedNonPreferenceKey.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package app.aaps.core.keys.interfaces
33
/**
44
* Preference key where key is a format string see [String::format]
55
*
6-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
6+
* Final key is composed as key + format, with %d and %s replaced by the arguments
77
*/
88
interface StringComposedNonPreferenceKey : NonPreferenceKey, ComposedKey {
99

1010
/**
1111
* Key is used as prefix for recognizing the preference
1212
*
13-
* Final key is composed as key + String.format(Locale.ENGLISH, format, *arguments)
13+
* Final key is composed as key + format, with %d and %s replaced by the arguments
1414
*/
1515
override val key: String
1616

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package app.aaps.core.keys.interfaces
2+
3+
import app.aaps.core.keys.BooleanComposedKey
4+
import app.aaps.core.keys.IntComposedKey
5+
import app.aaps.core.keys.LongComposedKey
6+
import app.aaps.core.keys.ProfileComposedBooleanKey
7+
import app.aaps.core.keys.ProfileComposedStringKey
8+
import com.google.common.truth.Truth.assertThat
9+
import com.google.common.truth.Truth.assertWithMessage
10+
import org.junit.jupiter.api.AfterEach
11+
import org.junit.jupiter.api.Test
12+
import org.junit.jupiter.api.assertThrows
13+
import java.util.Locale
14+
15+
/**
16+
* [ComposedKey.composeKey] builds SharedPreferences key names. If the text ever changes, users lose
17+
* the setting stored under the old name, so these tests pin it hard.
18+
*
19+
* The old code was `String.format(Locale.ENGLISH, key + format, *arguments)`. That call is the
20+
* oracle here: the new hand written version has to give exactly the same text.
21+
*/
22+
class ComposedKeyTest {
23+
24+
private val originalLocale: Locale = Locale.getDefault()
25+
26+
@AfterEach fun restoreLocale() {
27+
Locale.setDefault(originalLocale)
28+
}
29+
30+
/** Every real composed key in the app, with an argument that suits its format. */
31+
private fun allKeys(): List<Pair<ComposedKey, Any>> =
32+
BooleanComposedKey.entries.map { it to argumentFor(it) } +
33+
IntComposedKey.entries.map { it to argumentFor(it) } +
34+
LongComposedKey.entries.map { it to argumentFor(it) } +
35+
ProfileComposedBooleanKey.entries.map { it to argumentFor(it) } +
36+
ProfileComposedStringKey.entries.map { it to argumentFor(it) }
37+
38+
private fun argumentFor(key: ComposedKey): Any = if (key.format.contains("%d")) 7 else "abc"
39+
40+
/** The old implementation, kept here only to compare against. */
41+
private fun oldComposeKey(key: ComposedKey, argument: Any): String =
42+
String.format(Locale.ENGLISH, key.key + key.format, argument)
43+
44+
@Test
45+
fun `gives the same text as the old String format for every key`() {
46+
for ((key, argument) in allKeys())
47+
// Truth reads the message as a template, and key.format itself holds %s or %d,
48+
// so it has to go in as a placeholder value, not inside the text.
49+
assertWithMessage("key '%s' format '%s'", key.key, key.format)
50+
.that(key.composeKey(argument))
51+
.isEqualTo(oldComposeKey(key, argument))
52+
}
53+
54+
/**
55+
* The reason this was rewritten. A locale with its own digits would make String.format write
56+
* "%d" in that script unless a locale is passed every time. Building the text by hand cannot
57+
* go wrong that way.
58+
*/
59+
@Test
60+
fun `key text does not depend on the locale`() {
61+
val expected = allKeys().map { (key, argument) -> key.composeKey(argument) }
62+
for (tag in listOf("en", "de-DE", "ar-SA", "ne-NP", "bn-IN", "my-MM", "fa-IR", "th-TH-u-nu-thai")) {
63+
Locale.setDefault(Locale.forLanguageTag(tag))
64+
val actual = allKeys().map { (key, argument) -> key.composeKey(argument) }
65+
assertWithMessage("locale $tag").that(actual).isEqualTo(expected)
66+
}
67+
}
68+
69+
private fun key(k: String, f: String) = object : ComposedKey {
70+
override val key: String = k
71+
override val format: String = f
72+
}
73+
74+
@Test
75+
fun `substitutes d and s`() {
76+
assertThat(key("a_", "%d").composeKey(5)).isEqualTo("a_5")
77+
assertThat(key("a_", "%d").composeKey(-5)).isEqualTo("a_-5")
78+
assertThat(key("a_", "%d").composeKey(5L)).isEqualTo("a_5")
79+
assertThat(key("a_", "%s").composeKey("x")).isEqualTo("a_x")
80+
assertThat(key("a_", "%s").composeKey(true)).isEqualTo("a_true")
81+
assertThat(key("a_", "%d_%s").composeKey(1, "b")).isEqualTo("a_1_b")
82+
assertThat(key("a_", "").composeKey()).isEqualTo("a_")
83+
}
84+
85+
@Test
86+
fun `a doubled percent stays a single percent`() {
87+
assertThat(key("a_", "%%").composeKey()).isEqualTo("a_%")
88+
assertThat(key("a_", "%d%%").composeKey(5)).isEqualTo("a_5%")
89+
}
90+
91+
@Test
92+
fun `an unsupported format is refused`() {
93+
for (bad in listOf("%f", "%x", "%b", "%02d", "%.2f", "%1\$s")) {
94+
assertThrows<IllegalArgumentException>("format $bad should be refused") {
95+
key("a_", bad).composeKey(1)
96+
}
97+
}
98+
}
99+
100+
@Test
101+
fun `a lonely percent at the end is refused`() {
102+
assertThrows<IllegalArgumentException> { key("a_", "%").composeKey() }
103+
}
104+
105+
@Test
106+
fun `a value that is not a whole number is refused for d`() {
107+
assertThrows<IllegalArgumentException> { key("a_", "%d").composeKey(1.5) }
108+
assertThrows<IllegalArgumentException> { key("a_", "%d").composeKey("1") }
109+
}
110+
111+
@Test
112+
fun `a wrong number of arguments is refused`() {
113+
assertThrows<IllegalArgumentException> { key("a_", "%d").composeKey() }
114+
assertThrows<IllegalArgumentException> { key("a_", "%d").composeKey(1, 2) }
115+
assertThrows<IllegalArgumentException> { key("a_", "").composeKey(1) }
116+
}
117+
}

0 commit comments

Comments
 (0)