mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
opt-in usage notes and a stats dashboard
Release builds ask in setup and Settings whether to send a short note when the app is opened and after each drive: the version, plus the Android version, phone model and cars people tick. Nothing runs in the background. Dev builds always send. The Cloudflare Worker in stats-worker/ stores the notes and serves a private dashboard with GitHub downloads alongside.
This commit is contained in:
1 parent
b6bd6497b2
commit
5a3bbe82a0
13 files changed
+812
-6
No files matched your search
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -150,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,10 @@ object SessionReport {
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
@@ -59,6 +63,8 @@ object SessionReport {
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnitInfo = null
|
||||
carScreen = ""; carDpi = 0
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
@@ -68,6 +74,14 @@ object SessionReport {
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
|
||||
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
|
||||
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
|
||||
|
||||
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
|
||||
@Volatile var carScreen = ""; private set
|
||||
@Volatile var carDpi = 0; private set
|
||||
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
@@ -121,6 +135,18 @@ object SessionReport {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
appContext?.let { context ->
|
||||
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
}
|
||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package app.seb3thehacker.gearslip.stats
|
||||
|
||||
import android.app.job.JobScheduler
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Opt-in usage counts: a short note to the stats Worker (stats-worker/ in the repo). Off until the
|
||||
* driver turns it on in setup or Settings. Only the app version is always sent; everything else
|
||||
* is one of the driver's [Choices]. A random install ID counts a phone once; nothing ties it to a
|
||||
* person, and the Worker keeps no IP addresses.
|
||||
*
|
||||
* Notes go out only while Gearslip is in use: the first time the app is opened each day, and
|
||||
* after every car session. Nothing runs in the background, so a phone that stops using Gearslip
|
||||
* goes quiet. A note that fails waits for the next one. With no [BuildConfig.STATS_URL] set at
|
||||
* build time, nothing is sent.
|
||||
*/
|
||||
object UsageStats {
|
||||
|
||||
/** What the driver agreed to share beyond the app version. */
|
||||
data class Choices(
|
||||
val android: Boolean = true,
|
||||
val phone: Boolean = true,
|
||||
val cars: Boolean = true,
|
||||
)
|
||||
|
||||
private const val PREFS = "usage_stats"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_ASKED = "asked"
|
||||
private const val KEY_ID = "install_id"
|
||||
/** The phone details the server last got, and when, so they go again only on a change. */
|
||||
private const val KEY_SENT_DETAILS = "sent_details"
|
||||
private const val KEY_DETAILS_DAY = "details_day"
|
||||
private const val DETAILS_REFRESH_DAYS = 30L
|
||||
private const val KEY_CARS = "cars"
|
||||
private const val KEY_SENT_DAY = "sent_day"
|
||||
/** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
|
||||
private const val OLD_JOB_ID = 4_207
|
||||
private const val MAX_CARS = 10
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Dev builds are the developer's own, so they always send everything and never ask: no switch
|
||||
* in Settings, no page in setup. Only release builds offer the choice.
|
||||
*/
|
||||
val choosable: Boolean get() = !BuildConfig.DEBUG
|
||||
|
||||
fun enabled(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ENABLED, false)
|
||||
|
||||
/** Whether the driver has answered the question in setup, either way. */
|
||||
fun asked(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ASKED, false)
|
||||
|
||||
fun choices(context: Context): Choices {
|
||||
if (!choosable) return Choices()
|
||||
val p = prefs(context)
|
||||
return Choices(
|
||||
android = p.getBoolean("share_android", true),
|
||||
phone = p.getBoolean("share_phone", true),
|
||||
cars = p.getBoolean("share_cars", true),
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
||||
prefs(context).edit()
|
||||
.putBoolean(KEY_ENABLED, enabled)
|
||||
.putBoolean(KEY_ASKED, true)
|
||||
.putBoolean("share_android", choices.android)
|
||||
.putBoolean("share_phone", choices.phone)
|
||||
.putBoolean("share_cars", choices.cars)
|
||||
.apply()
|
||||
if (enabled) sendSoon(context)
|
||||
else prefs(context).edit().remove(KEY_CARS).remove(KEY_SENT_DAY).apply()
|
||||
}
|
||||
|
||||
/** Gearslip was opened: send today's note unless one already went out. */
|
||||
fun onAppOpened(context: Context) {
|
||||
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID)
|
||||
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
|
||||
}
|
||||
|
||||
private val sending = AtomicBoolean(false)
|
||||
@Volatile private var sendAgain = false
|
||||
/** Guards the saved car list, which a session's end and a finished send both rewrite. */
|
||||
private val carsLock = Any()
|
||||
|
||||
/**
|
||||
* Sends off the calling thread. A call while a note is in flight sends once more after it, so
|
||||
* a car recorded mid-send still goes out.
|
||||
*/
|
||||
private fun sendSoon(context: Context) {
|
||||
if (!enabled(context)) return
|
||||
if (!sending.compareAndSet(false, true)) { sendAgain = true; return }
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
try {
|
||||
do { sendAgain = false } while (send(app) && sendAgain)
|
||||
} finally {
|
||||
sending.set(false)
|
||||
}
|
||||
}, "gearslip-stats").start()
|
||||
}
|
||||
|
||||
private fun installId(context: Context): String {
|
||||
val p = prefs(context)
|
||||
return p.getString(KEY_ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(KEY_ID, it).apply() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A car session ended. [info] is null when the car hung up before it said who it is, for
|
||||
* instance after rejecting the certificate; the protocol version and [outcome] still say a lot.
|
||||
*/
|
||||
fun recordSession(
|
||||
context: Context,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String = "",
|
||||
dpi: Int = 0,
|
||||
) {
|
||||
if (!enabled(context)) return
|
||||
val p = prefs(context)
|
||||
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
|
||||
sendSoon(context)
|
||||
}
|
||||
|
||||
private fun addCar(
|
||||
p: android.content.SharedPreferences,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String,
|
||||
dpi: Int,
|
||||
) {
|
||||
val cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||
val car = JSONObject()
|
||||
.put("name", info?.headUnitName.orEmpty())
|
||||
.put("car", info?.carModel.orEmpty())
|
||||
.put("year", info?.carYear.orEmpty())
|
||||
.put("make", info?.headUnitMake.orEmpty())
|
||||
.put("model", info?.headUnitModel.orEmpty())
|
||||
.put("protocol", protocol)
|
||||
.put("result", outcome)
|
||||
if (screen.isNotEmpty()) car.put("screen", screen).put("dpi", dpi)
|
||||
// The same car and outcome twice in a day is one fact, not two.
|
||||
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
|
||||
if (!seen && cars.length() < MAX_CARS) cars.put(car)
|
||||
p.edit().putString(KEY_CARS, cars.toString()).apply()
|
||||
}
|
||||
|
||||
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
|
||||
private fun isGrapheneOs(context: Context) =
|
||||
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
|
||||
|
||||
private fun details(context: Context, c: Choices) = JSONObject().apply {
|
||||
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
|
||||
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
/**
|
||||
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
|
||||
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
|
||||
*/
|
||||
fun preview(context: Context, everything: Boolean = false): JSONObject {
|
||||
val c = choices(context)
|
||||
val p = prefs(context)
|
||||
val note = JSONObject()
|
||||
.put("id", installId(context))
|
||||
.put("version", BuildConfig.VERSION_NAME)
|
||||
.put("code", BuildConfig.VERSION_CODE)
|
||||
.put("build", if (BuildConfig.DEBUG) "dev" else "release")
|
||||
// Phone details change rarely, so they ride along only when they differ from what the
|
||||
// server last got, or once a month in case it lost them. Unticked ones are left out, and
|
||||
// the server keeps the last value it had.
|
||||
val details = details(context, c)
|
||||
val stale = p.getString(KEY_DETAILS_DAY, null)?.let { LocalDate.parse(it).plusDays(DETAILS_REFRESH_DAYS) <= LocalDate.now() } ?: true
|
||||
if (details.length() > 0 && (everything || stale || details.toString() != p.getString(KEY_SENT_DETAILS, null))) {
|
||||
details.keys().forEach { note.put(it, details.get(it)) }
|
||||
}
|
||||
if (c.cars) note.put("cars", runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray()))
|
||||
return note
|
||||
}
|
||||
|
||||
/** Sends the note now; false when it didn't get through. */
|
||||
private fun send(context: Context): Boolean {
|
||||
if (!enabled(context)) return true
|
||||
val endpoint = BuildConfig.STATS_URL
|
||||
if (endpoint.isBlank()) return true
|
||||
val p = prefs(context)
|
||||
val note = synchronized(carsLock) { preview(context) }
|
||||
val sentCars = note.optJSONArray("cars")?.toString()
|
||||
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
|
||||
val body = note.toString().toByteArray()
|
||||
return runCatching {
|
||||
val conn = (URL("$endpoint/ping").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
conn.outputStream.use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
if (code in 200..299) {
|
||||
// Sent: the cars start over, unless a session ended meanwhile and added one, which
|
||||
// stays for the next note.
|
||||
synchronized(carsLock) {
|
||||
val edit = p.edit().putString(KEY_SENT_DAY, LocalDate.now().toString())
|
||||
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
|
||||
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
|
||||
edit.apply()
|
||||
}
|
||||
GearslipLog.i("stats: note sent")
|
||||
true
|
||||
} else {
|
||||
GearslipLog.w("stats: the server answered $code")
|
||||
false
|
||||
}
|
||||
}.getOrElse {
|
||||
GearslipLog.w("stats: could not send: ${it.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||
|
||||
private const val INTRO_START =
|
||||
"Gearslip can't tell which cars it works in unless drivers say so. Turn this on, and Gearslip " +
|
||||
"sends a short note when you open it and after each drive, never in the background. The " +
|
||||
"note carries the Gearslip version and whatever you tick below. "
|
||||
private const val INTRO_PRIVACY =
|
||||
"It holds no name, account, or location, only a random number so your phone counts once. " +
|
||||
"Cloudflare carries the note, and the server keeps no IP address."
|
||||
private const val INTRO_END =
|
||||
" Nothing helps the project more: these notes tell the next driver whether their car " +
|
||||
"will work before they install."
|
||||
|
||||
/** The pitch for the usage notes, with the privacy promise in bold so it isn't missed. */
|
||||
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||
append(INTRO_START)
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||
append(INTRO_END)
|
||||
}
|
||||
|
||||
/** The usage notes card for the setup guide: the same rows Settings shows, minus the note. */
|
||||
@Composable
|
||||
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
||||
SettingsCard(modifier) { UsageStatsRows(showNote = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The on switch, what to share, and (with [showNote]) the note itself, saved as they change.
|
||||
* Rows only: the caller puts them in a card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.UsageStatsRows(showNote: Boolean) {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
|
||||
var choices by remember { mutableStateOf(UsageStats.choices(context)) }
|
||||
var noteOpen by remember { mutableStateOf(false) }
|
||||
fun save() = UsageStats.set(context, on, choices)
|
||||
|
||||
SettingsRow(
|
||||
"Send usage notes",
|
||||
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||
subtitle = if (BuildConfig.STATS_URL.isBlank()) "This build has no stats server, so it sends nothing" else null,
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
AnimatedVisibility(on) {
|
||||
Column {
|
||||
SettingsDivider()
|
||||
Text(
|
||||
"Also share",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
|
||||
choices = choices.copy(cars = it)
|
||||
save()
|
||||
}
|
||||
if (showNote) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
Modifier.clickable { noteOpen = !noteOpen },
|
||||
subtitle = "The Android version and phone model go only when they change, and once a month",
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = {
|
||||
Icon(
|
||||
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(noteOpen) {
|
||||
// Rebuilt from the saved choices each time one changes, so it always matches.
|
||||
val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
note,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
|
||||
@Composable
|
||||
private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange)
|
||||
.padding(start = 56.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user