You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-07-06 06:07:29 +02:00
add configurable widget
This commit is contained in:
@ -114,10 +114,14 @@ dependencies {
|
||||
|
||||
//Glance Widget
|
||||
implementation "androidx.glance:glance-appwidget:$compose_version"
|
||||
implementation("com.google.code.gson:gson:$gson_version")
|
||||
implementation "com.google.code.gson:gson:$gson_version"
|
||||
|
||||
implementation("io.coil-kt.coil3:coil-compose:$coil_version")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version")
|
||||
// Glance Configure
|
||||
implementation "androidx.activity:activity-compose:1.8.2"
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
implementation "androidx.compose.material3:material3:1.2.1"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
|
||||
}
|
||||
|
||||
// This is uncommented in F-Droid build script
|
||||
|
@ -152,7 +152,7 @@
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget" />
|
||||
android:resource="@xml/random_widget" />
|
||||
</receiver>
|
||||
|
||||
<!-- <receiver-->
|
||||
@ -165,6 +165,16 @@
|
||||
<!-- android:name="android.appwidget.provider"-->
|
||||
<!-- android:resource="@xml/widget" />-->
|
||||
<!-- </receiver>-->
|
||||
|
||||
<activity android:name=".widget.configure.RandomConfigure"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
|
||||
>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.*
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@ -15,7 +14,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
|
||||
@ -28,23 +26,27 @@ class ImageDownloadWorker(
|
||||
|
||||
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
|
||||
|
||||
fun enqueue(context: Context, appWidgetId: Int, widgetType: WidgetType) {
|
||||
private fun buildConstraints(): Constraints {
|
||||
return Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data {
|
||||
return Data.Builder()
|
||||
.putString(kWorkerWidgetType, widgetType.toString())
|
||||
.putInt(kWorkerWidgetID, appWidgetId)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) {
|
||||
val manager = WorkManager.getInstance(context)
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
|
||||
20, TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString("widgetType", widgetType.toString())
|
||||
.putInt("widgetId", appWidgetId)
|
||||
.build()
|
||||
)
|
||||
.setConstraints(buildConstraints())
|
||||
.setInputData(buildInputData(appWidgetId, widgetType))
|
||||
.addTag(appWidgetId.toString())
|
||||
.build()
|
||||
|
||||
@ -55,21 +57,35 @@ class ImageDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) {
|
||||
val manager = WorkManager.getInstance(context)
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<ImageDownloadWorker>()
|
||||
.setConstraints(buildConstraints())
|
||||
.setInputData(buildInputData(appWidgetId, widgetType))
|
||||
.addTag(appWidgetId.toString())
|
||||
.build()
|
||||
|
||||
manager.enqueueUniqueWork(
|
||||
"$uniqueWorkName-$appWidgetId",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, glanceId: GlanceId) {
|
||||
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId)
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(appWidgetId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
val widgetType = WidgetType.valueOf(inputData.getString("config") ?: "")
|
||||
val widgetId = inputData.getInt("widgetId", -1)
|
||||
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
|
||||
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
|
||||
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||
val currentImgUUID = currentState[stringPreferencesKey("uuid")]
|
||||
val currentImgUUID = currentState[kImageUUID]
|
||||
|
||||
val serverConfig = ImmichAPI.getServerConfig(context)
|
||||
|
||||
@ -112,9 +128,9 @@ class ImageDownloadWorker(
|
||||
|
||||
private suspend fun updateWidget(type: WidgetType, glanceId: GlanceId, imageUUID: String, widgetState: WidgetState = WidgetState.SUCCESS) {
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
prefs[longPreferencesKey("now")] = System.currentTimeMillis()
|
||||
prefs[stringPreferencesKey("uuid")] = imageUUID
|
||||
prefs[stringPreferencesKey("state")] = widgetState.toString()
|
||||
prefs[kNow] = System.currentTimeMillis()
|
||||
prefs[kImageUUID] = imageUUID
|
||||
prefs[kWidgetState] = widgetState.toString()
|
||||
}
|
||||
|
||||
when (type) {
|
||||
@ -126,7 +142,7 @@ class ImageDownloadWorker(
|
||||
val api = ImmichAPI(serverConfig)
|
||||
|
||||
val filters = SearchFilters(AssetType.IMAGE, size=1)
|
||||
val albumId = widgetData[stringPreferencesKey("albumID")]
|
||||
val albumId = widgetData[kSelectedAlbum]
|
||||
|
||||
if (albumId != null) {
|
||||
filters.albumIds = listOf(albumId)
|
||||
@ -139,13 +155,13 @@ class ImageDownloadWorker(
|
||||
}
|
||||
|
||||
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
|
||||
val file = File(context.cacheDir, "widget_image_$uuid.jpg")
|
||||
val file = File(context.cacheDir, imageFilename(uuid))
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private suspend fun saveImage(bitmap: Bitmap): String = withContext(Dispatchers.IO) {
|
||||
val uuid = UUID.randomUUID().toString()
|
||||
val file = File(context.cacheDir, "widget_image_$uuid.jpg")
|
||||
val file = File(context.cacheDir, imageFilename(uuid))
|
||||
FileOutputStream(file).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
package app.alextran.immich.widget
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
@ -51,3 +54,17 @@ enum class WidgetState {
|
||||
|
||||
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
|
||||
|
||||
// MARK: Widget State Keys
|
||||
val kImageUUID = stringPreferencesKey("uuid")
|
||||
val kSubtitleText = stringPreferencesKey("subtitle")
|
||||
val kNow = longPreferencesKey("now")
|
||||
val kWidgetState = stringPreferencesKey("state")
|
||||
val kSelectedAlbum = stringPreferencesKey("albumID")
|
||||
val kShowAlbumName = booleanPreferencesKey("showAlbumName")
|
||||
|
||||
const val kWorkerWidgetType = "widgetType"
|
||||
const val kWorkerWidgetID = "widgetId"
|
||||
|
||||
fun imageFilename(id: String): String {
|
||||
return "widget_image_$id.jpg"
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ class RandomReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
|
||||
appWidgetIds.forEach { widgetID ->
|
||||
ImageDownloadWorker.enqueue(context, widgetID, WidgetType.RANDOM)
|
||||
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,15 +17,15 @@ class RandomWidget : GlanceAppWidget() {
|
||||
|
||||
provideContent {
|
||||
val prefs = currentState<MutablePreferences>()
|
||||
val imageUUID = prefs[stringPreferencesKey("uuid")]
|
||||
val imageUUID = prefs[kImageUUID]
|
||||
|
||||
val subtitle: String? = prefs[stringPreferencesKey("subtitle")]
|
||||
val subtitle: String? = prefs[kSubtitleText]
|
||||
var bitmap: Bitmap? = null
|
||||
var loggedIn = true
|
||||
|
||||
if (imageUUID != null) {
|
||||
// fetch a random photo from server
|
||||
val file = File(context.cacheDir, "widget_image_$imageUUID.jpg")
|
||||
val file = File(context.cacheDir, imageFilename(id))
|
||||
|
||||
if (file.exists()) {
|
||||
bitmap = loadScaledBitmap(file, 500, 500)
|
||||
|
@ -0,0 +1,72 @@
|
||||
package app.alextran.immich.widget.configure
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.geometry.*
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.*
|
||||
|
||||
data class DropdownItem (
|
||||
val label: String,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
// Creating a composable to display a drop down menu
|
||||
@Composable
|
||||
fun Dropdown(items: List<DropdownItem>,
|
||||
selectedItem: DropdownItem?,
|
||||
onItemSelected: (DropdownItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "",) {
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var textFieldSize by remember { mutableStateOf(Size.Zero) }
|
||||
|
||||
// Toggle icon based on expanded state
|
||||
val icon = if (expanded)
|
||||
Icons.Filled.KeyboardArrowUp
|
||||
else
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
|
||||
Column(modifier) {
|
||||
OutlinedTextField(
|
||||
value = selectedItem?.label ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
textFieldSize = coordinates.size.toSize()
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = "Dropdown icon",
|
||||
modifier = Modifier.clickable { expanded = !expanded }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() })
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item.label) },
|
||||
onClick = {
|
||||
onItemSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package app.alextran.immich.widget.configure
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun LightDarkTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(), // ← This line is key
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val colorScheme = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme ->
|
||||
dynamicDarkColorScheme(context)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme ->
|
||||
dynamicLightColorScheme(context)
|
||||
isDarkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
package app.alextran.immich.widget.configure
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import app.alextran.immich.widget.ImageDownloadWorker
|
||||
import app.alextran.immich.widget.ImmichAPI
|
||||
import app.alextran.immich.widget.WidgetState
|
||||
import app.alextran.immich.widget.WidgetType
|
||||
import app.alextran.immich.widget.kSelectedAlbum
|
||||
import app.alextran.immich.widget.kShowAlbumName
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RandomConfigure : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Get widget ID from intent
|
||||
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val glanceId = GlanceAppWidgetManager(applicationContext)
|
||||
.getGlanceIdBy(appWidgetId)
|
||||
|
||||
setContent {
|
||||
LightDarkTheme {
|
||||
RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = {
|
||||
finish()
|
||||
Log.w("WIDGET_ACTIVITY", "SAVING")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) {
|
||||
|
||||
var selectedAlbum by remember { mutableStateOf<DropdownItem?>(null) }
|
||||
var showAlbumName by remember { mutableStateOf(false) }
|
||||
var availableAlbums by remember { mutableStateOf<List<DropdownItem>>(listOf()) }
|
||||
var state by remember { mutableStateOf(WidgetState.LOADING) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// get albums from server
|
||||
val serverCfg = ImmichAPI.getServerConfig(context)
|
||||
val api: ImmichAPI?
|
||||
|
||||
if (serverCfg == null) {
|
||||
state = WidgetState.LOG_IN
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
api = ImmichAPI(serverCfg)
|
||||
val albumItems = api.fetchAlbums().map {
|
||||
DropdownItem(it.albumName, it.id)
|
||||
}
|
||||
|
||||
availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems
|
||||
state = WidgetState.SUCCESS
|
||||
|
||||
// load selected configuration
|
||||
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||
val currentAlbumId = currentState[kSelectedAlbum]
|
||||
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
|
||||
selectedAlbum = albumEntity ?: availableAlbums.first()
|
||||
|
||||
// load showAlbumName
|
||||
showAlbumName = currentState[kShowAlbumName] ?: false
|
||||
}
|
||||
|
||||
suspend fun saveConfiguration() {
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
|
||||
prefs[kShowAlbumName] = showAlbumName
|
||||
}
|
||||
|
||||
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar (
|
||||
title = { Text("Widget Configuration") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
saveConfiguration()
|
||||
onDone()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding), // Respect the top bar
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
when (state) {
|
||||
WidgetState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
WidgetState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
|
||||
WidgetState.SUCCESS -> {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("Album")
|
||||
Dropdown(
|
||||
items = availableAlbums,
|
||||
selectedItem = selectedAlbum,
|
||||
onItemSelected = { selectedAlbum = it },
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "Show Album Name")
|
||||
Switch(
|
||||
checked = showAlbumName,
|
||||
onCheckedChange = { showAlbumName = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
mobile/android/app/src/main/res/xml/random_widget.xml
Normal file
11
mobile/android/app/src/main/res/xml/random_widget.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1200000"
|
||||
android:configure="app.alextran.immich.widget.configure.RandomConfigure"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||
tools:targetApi="28"
|
||||
/>
|
@ -1,7 +0,0 @@
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1200000"
|
||||
/>
|
Reference in New Issue
Block a user