Android M1: meeting recording via foreground service
- core/recording: MeetingRecorder (AudioRecord at the device's native rate, WAV on disk + 60 s rolling window resampled to 16 kHz), streaming WavWriter, thread-safe RollingWindow, linear resampler; WavReader moved here from the app - app: RecorderService (foreground, type microphone) with ongoing notification and StateFlow state; Record/Stop UI with timer and level meter; runtime permission flow; finished recordings auto-load for transcription - launcher icon (mic + waveform, matching the desktop brand) and notification glyph - fix real M0 bug caught by the new unit tests: WavReader parsed 16-bit fmt fields (audioFormat/channels/bitsPerSample) with 32-bit reads - JVM tests: WAV round trip, native-rate header, resampler, rolling window — 5/5 green; assembleDebug and aapt2 APK checks pass - validated on-device on a Fairphone 6 (Android 16): model download, engine load, recording and transcription all working
This commit is contained in:
@@ -32,6 +32,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:recording"))
|
||||
implementation(project(":core:whisper"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Only used to download Whisper GGML models from Hugging Face. -->
|
||||
<!-- Model downloads from Hugging Face. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Meeting recording (foreground service, type microphone). -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="MeetRec"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:allowBackup="true">
|
||||
@@ -17,5 +23,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".RecorderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -16,25 +19,29 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.unit.dp
|
||||
import com.meetrec.core.recording.WavReader
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,9 +49,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* M0 screen: proves the whisper.cpp engine on device.
|
||||
* Pick a model (downloads from Hugging Face), load it, pick a WAV file,
|
||||
* transcribe, share the text. Recording arrives in M1.
|
||||
* M1 screen: record a meeting (foreground service), then transcribe the
|
||||
* recording on device. Live transcript arrives with M2.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -72,6 +78,8 @@ fun MeetRecScreen() {
|
||||
val modelsDir = remember { File(context.filesDir, "models") }
|
||||
val modelFile = ModelDownloader.modelFile(model, modelsDir)
|
||||
|
||||
val recState by RecorderService.state.collectAsState()
|
||||
|
||||
fun <T> runBusy(block: suspend () -> T) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
@@ -86,8 +94,61 @@ fun MeetRecScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// A finished recording is auto-loaded for transcription.
|
||||
LaunchedEffect(recState) {
|
||||
val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect
|
||||
runBusy {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
fin.file.inputStream().use { WavReader.read(it) }
|
||||
}
|
||||
wavName = fin.file.name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "Recorded ${fmtMs(fin.durationMs)} at ${fin.sampleRate} Hz" +
|
||||
" — ready to transcribe"
|
||||
}
|
||||
}
|
||||
(recState as? RecordingState.Failed)?.let {
|
||||
LaunchedEffect(it) { status = "Recording failed: ${it.message}" }
|
||||
}
|
||||
|
||||
fun startRecording() = context.startForegroundService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_START),
|
||||
)
|
||||
|
||||
fun stopRecording() = context.startService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_STOP),
|
||||
)
|
||||
|
||||
val permLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.RECORD_AUDIO] == true) {
|
||||
startRecording()
|
||||
} else {
|
||||
status = "Microphone permission is required to record"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (recState is RecordingState.Recording) {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
val wanted = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val missing = wanted.filter {
|
||||
context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) startRecording() else permLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val pickWav = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runBusy {
|
||||
@@ -101,7 +162,7 @@ fun MeetRecScreen() {
|
||||
wavName = name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "$name: ${samples.size / WavReader.TARGET_RATE} s @ 16 kHz"
|
||||
status = "$name: ${samples.size / 16000} s @ 16 kHz"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +233,41 @@ fun MeetRecScreen() {
|
||||
) { Text("Load engine") }
|
||||
}
|
||||
|
||||
// wav + transcribe actions
|
||||
// recording
|
||||
val recording = recState as? RecordingState.Recording
|
||||
Button(
|
||||
onClick = { toggleRecording() },
|
||||
enabled = !busy || recording != null,
|
||||
colors = if (recording == null) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (recording == null) {
|
||||
"\u25CF Record"
|
||||
} else {
|
||||
"\u25A0 Stop (${fmtMs(recording.elapsedMs)})"
|
||||
},
|
||||
)
|
||||
}
|
||||
if (recording != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { recording.level.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// transcribe actions
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
|
||||
enabled = !busy,
|
||||
) { Text(if (wavName == null) "Pick WAV file" else "Pick another WAV") }
|
||||
) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") }
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = wavSamples
|
||||
@@ -194,8 +284,8 @@ fun MeetRecScreen() {
|
||||
}
|
||||
segments = segs
|
||||
val secs = (System.currentTimeMillis() - start) / 1000.0
|
||||
status = "${segs.size} segments in $secs s (audio " +
|
||||
"${samples.size / WavReader.TARGET_RATE} s)"
|
||||
status = "${segs.size} segments in $secs s " +
|
||||
"(audio ${samples.size / 16000} s)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -214,7 +304,7 @@ fun MeetRecScreen() {
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text),
|
||||
"Share transcript",
|
||||
)
|
||||
),
|
||||
)
|
||||
}) { Text("Share transcript") }
|
||||
}
|
||||
@@ -238,5 +328,8 @@ fun MeetRecScreen() {
|
||||
|
||||
private fun fmtMs(ms: Long): String {
|
||||
val total = ms / 1000
|
||||
return "%02d:%02d".format(total / 60, total % 60)
|
||||
val h = total / 3600
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.meetrec.core.recording.MeetingRecorder
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Recording state published to the UI (collect RecorderService.state). */
|
||||
sealed interface RecordingState {
|
||||
data object Idle : RecordingState
|
||||
data class Recording(val file: File, val elapsedMs: Long, val level: Float) :
|
||||
RecordingState
|
||||
|
||||
data class Finished(
|
||||
val file: File,
|
||||
val durationMs: Long,
|
||||
val sampleRate: Int,
|
||||
) : RecordingState
|
||||
|
||||
data class Failed(val message: String) : RecordingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground service (type: microphone) that owns the MeetingRecorder,
|
||||
* mirroring the desktop recorder lifecycle. Keeps recording alive while
|
||||
* the app is backgrounded and posts an ongoing notification.
|
||||
*/
|
||||
class RecorderService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.meetrec.android.action.START"
|
||||
const val ACTION_STOP = "com.meetrec.android.action.STOP"
|
||||
private const val CHANNEL_ID = "meetrec_recording"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
val state: StateFlow<RecordingState> = MutableStateFlow(RecordingState.Idle)
|
||||
private val _state = state as MutableStateFlow<RecordingState>
|
||||
}
|
||||
|
||||
private var recorder: MeetingRecorder? = null
|
||||
private var currentFile: File? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> recorder?.stop()
|
||||
else -> startRecording()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
if (recorder != null) return
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_state.value = RecordingState.Failed("Microphone permission is missing")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
createChannel()
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification("Recording meeting…"),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
)
|
||||
|
||||
val dir = File(filesDir, "recordings").apply { mkdirs() }
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
currentFile = File(dir, "meeting-$stamp.wav")
|
||||
|
||||
val rec = MeetingRecorder(currentFile!!, listener)
|
||||
recorder = rec
|
||||
_state.value = RecordingState.Recording(currentFile!!, 0, 0f)
|
||||
rec.start()
|
||||
}
|
||||
|
||||
private val listener = object : MeetingRecorder.Listener {
|
||||
override fun onTick(elapsedMs: Long, level: Float) {
|
||||
currentFile?.let { _state.value = RecordingState.Recording(it, elapsedMs, level) }
|
||||
}
|
||||
|
||||
override fun onFinish(file: File, durationMs: Long, sampleRate: Int) {
|
||||
_state.value = RecordingState.Finished(file, durationMs, sampleRate)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
_state.value = RecordingState.Failed(message)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
recorder = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
recorder?.stop()
|
||||
recorder = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Meeting recording",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notification(text: String): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_mic)
|
||||
.setContentTitle("MeetRec")
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pi)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Reads a PCM WAV file into 16 kHz mono float32 — the format Whisper wants.
|
||||
*
|
||||
* Supports 16-bit PCM and 32-bit IEEE-float WAVs of any channel count and
|
||||
* sample rate (multi-channel is averaged, rates are linearly resampled).
|
||||
* Pure Kotlin so it is unit-testable on the JVM.
|
||||
*/
|
||||
object WavReader {
|
||||
|
||||
const val TARGET_RATE = 16000
|
||||
|
||||
fun read(input: InputStream): FloatArray {
|
||||
val data = input.readBytes()
|
||||
require(data.size > 44) { "file too small to be a WAV" }
|
||||
require(String(data, 0, 4) == "RIFF" && String(data, 8, 4) == "WAVE") {
|
||||
"not a RIFF/WAVE file"
|
||||
}
|
||||
|
||||
var channels = 0
|
||||
var sampleRate = 0
|
||||
var audioFormat = 0
|
||||
var bitsPerSample = 0
|
||||
var samples: FloatArray? = null
|
||||
|
||||
var pos = 12
|
||||
while (pos + 8 <= data.size) {
|
||||
val id = String(data, pos, 4)
|
||||
val size = leInt(data, pos + 4)
|
||||
val body = pos + 8
|
||||
when (id) {
|
||||
"fmt " -> {
|
||||
audioFormat = leInt(data, body)
|
||||
channels = leInt(data, body + 2)
|
||||
sampleRate = leInt(data, body + 4)
|
||||
bitsPerSample = leInt(data, body + 14)
|
||||
}
|
||||
"data" -> {
|
||||
val fmt = audioFormat
|
||||
require(fmt == 1 || fmt == 3) {
|
||||
"unsupported WAV audio format $fmt (need PCM or IEEE float)"
|
||||
}
|
||||
require(bitsPerSample == 16 || bitsPerSample == 32) {
|
||||
"unsupported bit depth $bitsPerSample (need 16 or 32)"
|
||||
}
|
||||
require(channels > 0 && sampleRate > 0) { "malformed fmt chunk" }
|
||||
val len = minOf(size, data.size - body)
|
||||
samples = decode(data, body, len, fmt, bitsPerSample, channels)
|
||||
}
|
||||
}
|
||||
pos = body + size + (size and 1) // chunks are word-aligned
|
||||
}
|
||||
|
||||
val mono = samples ?: error("no data chunk found")
|
||||
return if (sampleRate == TARGET_RATE) mono else resample(mono, sampleRate, TARGET_RATE)
|
||||
}
|
||||
|
||||
private fun decode(
|
||||
data: ByteArray, body: Int, len: Int,
|
||||
audioFormat: Int, bits: Int, channels: Int,
|
||||
): FloatArray {
|
||||
val bytesPerSample = bits / 8
|
||||
val frames = len / (bytesPerSample * channels)
|
||||
val out = FloatArray(frames)
|
||||
for (f in 0 until frames) {
|
||||
var acc = 0f
|
||||
for (c in 0 until channels) {
|
||||
val o = body + (f * channels + c) * bytesPerSample
|
||||
acc += when {
|
||||
audioFormat == 1 && bits == 16 ->
|
||||
((data[o].toInt() and 0xFF) or (data[o + 1].toInt() shl 8)) / 32768f
|
||||
audioFormat == 1 && bits == 32 ->
|
||||
leInt(data, o) / 2147483648f
|
||||
audioFormat == 3 && bits == 32 ->
|
||||
Float.fromBits(leInt(data, o))
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
out[f] = acc / channels
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun resample(x: FloatArray, from: Int, to: Int): FloatArray {
|
||||
if (x.isEmpty()) return x
|
||||
val n = (x.size.toDouble() * to / from).toInt().coerceAtLeast(1)
|
||||
val out = FloatArray(n)
|
||||
val step = from.toDouble() / to
|
||||
for (i in 0 until n) {
|
||||
val t = i * step
|
||||
val i0 = t.toInt().coerceAtMost(x.size - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(x.size - 1)
|
||||
val frac = (t - i0).toFloat()
|
||||
out[i] = x[i0] * (1f - frac) + x[i1] * frac
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun leInt(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or
|
||||
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- MeetRec glyph: mic + waveform (voice becoming transcript). -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M45.6,24.6h16.8a8.4,8.4 0 0 1 8.4,8.4v8.4a8.4,8.4 0 0 1 -8.4,8.4h-16.8a8.4,8.4 0 0 1 -8.4,-8.4v-8.4a8.4,8.4 0 0 1 8.4,-8.4z" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M37.2,42.6 v4.8 a16.8,16.8 0 0 0 33.6,0 v-4.8" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34.8,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M43.2,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M51.6,70.8h4.8a2.4,2.4 0 0 1 2.4,2.4v4.8a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-4.8a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M59.9,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M68.4,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_bg" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_bg" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launcher_bg">#C0392B</color>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user