Add Authentik/SSO login via Nextcloud Login Flow v2; fix Tasks widget minHeight
Login Flow v2 opens Nextcloud's own browser login page in a Custom Tab (androidx.browser), so any SSO backend configured server-side (Authentik via user_oidc) and WebAuthn hardware-key prompts work without NCal implementing OIDC/OAuth2 itself - the flow ends with a normal app password, stored the same way as manual login. The Tasks widget's minHeight was pinned exactly to its 4-row default size (Android's 70*cells-30 formula), leaving no slack to ever resize shorter. Lowered to 110dp (2 rows) so it can actually be resized to a 2x3 layout, which was verified to render cleanly.
This commit is contained in:
parent
d31894f980
commit
33d6469bbe
|
|
@ -77,6 +77,7 @@ dependencies {
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
|
||||||
implementation("androidx.activity:activity-compose:1.9.0")
|
implementation("androidx.activity:activity-compose:1.9.0")
|
||||||
|
implementation("androidx.browser:browser:1.8.0")
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.homelab.ncal.data.network
|
||||||
|
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class LoginFlowV2Session(val loginUrl: String, val pollEndpoint: String, val pollToken: String)
|
||||||
|
data class LoginFlowV2Result(val server: String, val loginName: String, val appPassword: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud's browser-based "Login Flow v2" - the same mechanism the official desktop/mobile
|
||||||
|
* apps use for "Log in with browser". The login page itself is rendered by Nextcloud, so it
|
||||||
|
* transparently supports whatever auth backends the server has configured (password, SSO via
|
||||||
|
* user_oidc/Authentik, WebAuthn hardware keys) without NCal needing to speak OIDC/OAuth2
|
||||||
|
* directly - the end result is always a normal Nextcloud app password, stored and used exactly
|
||||||
|
* like one the user typed in manually.
|
||||||
|
*/
|
||||||
|
class LoginFlowV2Client {
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Starts a flow: returns the URL to open in a browser plus the token to poll for completion. */
|
||||||
|
fun initiate(serverUrl: String): LoginFlowV2Session {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${serverUrl.trimEnd('/')}/index.php/login/v2")
|
||||||
|
.post(FormBody.Builder().build())
|
||||||
|
.build()
|
||||||
|
http.newCall(request).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
throw CalDavException("HTTP ${resp.code} starting browser login", resp.code)
|
||||||
|
}
|
||||||
|
val json = JSONObject(resp.body?.string().orEmpty())
|
||||||
|
val poll = json.getJSONObject("poll")
|
||||||
|
return LoginFlowV2Session(
|
||||||
|
loginUrl = json.getString("login"),
|
||||||
|
pollEndpoint = poll.getString("endpoint"),
|
||||||
|
pollToken = poll.getString("token")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns null while the user hasn't finished the browser flow yet (server 404s until then). */
|
||||||
|
fun poll(session: LoginFlowV2Session): LoginFlowV2Result? {
|
||||||
|
val body = FormBody.Builder().add("token", session.pollToken).build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(session.pollEndpoint)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
http.newCall(request).execute().use { resp ->
|
||||||
|
if (resp.code == 404) return null
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
throw CalDavException("HTTP ${resp.code} polling browser login", resp.code)
|
||||||
|
}
|
||||||
|
val json = JSONObject(resp.body?.string() ?: return null)
|
||||||
|
return LoginFlowV2Result(
|
||||||
|
server = json.getString("server"),
|
||||||
|
loginName = json.getString("loginName"),
|
||||||
|
appPassword = json.getString("appPassword")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package com.homelab.ncal.ui.login
|
package com.homelab.ncal.ui.login
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -12,17 +15,21 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -34,11 +41,19 @@ fun LoginScreen(
|
||||||
onLoggedIn: () -> Unit
|
onLoggedIn: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(state.success) {
|
LaunchedEffect(state.success) {
|
||||||
if (state.success) onLoggedIn()
|
if (state.success) onLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.browserLoginUrl) {
|
||||||
|
state.browserLoginUrl?.let { url ->
|
||||||
|
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||||
|
viewModel.browserLoginUrlConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -70,41 +85,84 @@ fun LoginScreen(
|
||||||
placeholder = { Text("nextcloud.yourdomain.com") },
|
placeholder = { Text("nextcloud.yourdomain.com") },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = !state.awaitingBrowserLogin,
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
|
||||||
value = state.username,
|
|
||||||
onValueChange = viewModel::onUsernameChange,
|
|
||||||
label = { Text("Username") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.appPassword,
|
|
||||||
onValueChange = viewModel::onPasswordChange,
|
|
||||||
label = { Text("App password") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(bottom = 20.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
state.error?.let {
|
if (state.awaitingBrowserLogin) {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp))
|
CircularProgressIndicator(modifier = Modifier.padding(vertical = 12.dp))
|
||||||
}
|
Text(
|
||||||
|
"Finish logging in in the browser tab that just opened, including any SSO or " +
|
||||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
"security-key prompt \u2014 this screen will continue automatically.",
|
||||||
if (state.loading) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
textAlign = TextAlign.Center,
|
||||||
} else {
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
Text("Connect")
|
)
|
||||||
|
TextButton(onClick = viewModel::cancelBrowserLogin) {
|
||||||
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = viewModel::onUsernameChange,
|
||||||
|
label = { Text("Username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.appPassword,
|
||||||
|
onValueChange = viewModel::onPasswordChange,
|
||||||
|
label = { Text("App password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(bottom = 20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
state.error?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (state.loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Divider(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"or",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Divider(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::startBrowserLogin,
|
||||||
|
enabled = !state.loading,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Log in with browser")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"For SSO (e.g. Authentik) or hardware security keys.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 6.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,19 @@ package com.homelab.ncal.ui.login
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.homelab.ncal.data.network.LoginFlowV2Client
|
||||||
|
import com.homelab.ncal.data.network.LoginFlowV2Result
|
||||||
|
import com.homelab.ncal.data.network.LoginFlowV2Session
|
||||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
data class LoginUiState(
|
data class LoginUiState(
|
||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
|
|
@ -13,7 +22,11 @@ data class LoginUiState(
|
||||||
val appPassword: String = "",
|
val appPassword: String = "",
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val success: Boolean = false
|
val success: Boolean = false,
|
||||||
|
/** Set once to tell the UI to open a Custom Tab, then immediately consumed/cleared. */
|
||||||
|
val browserLoginUrl: String? = null,
|
||||||
|
/** True while waiting for the user to finish the browser flow and the poll loop is running. */
|
||||||
|
val awaitingBrowserLogin: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
|
|
@ -21,6 +34,9 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(LoginUiState())
|
private val _state = MutableStateFlow(LoginUiState())
|
||||||
val state: StateFlow<LoginUiState> = _state
|
val state: StateFlow<LoginUiState> = _state
|
||||||
|
|
||||||
|
private val flowClient = LoginFlowV2Client()
|
||||||
|
private var pollJob: Job? = null
|
||||||
|
|
||||||
fun onServerUrlChange(v: String) { _state.value = _state.value.copy(serverUrl = v, error = null) }
|
fun onServerUrlChange(v: String) { _state.value = _state.value.copy(serverUrl = v, error = null) }
|
||||||
fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v, error = null) }
|
fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v, error = null) }
|
||||||
fun onPasswordChange(v: String) { _state.value = _state.value.copy(appPassword = v, error = null) }
|
fun onPasswordChange(v: String) { _state.value = _state.value.copy(appPassword = v, error = null) }
|
||||||
|
|
@ -51,6 +67,78 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Starts Nextcloud's browser-based "Login Flow v2" - see [LoginFlowV2Client]. */
|
||||||
|
fun startBrowserLogin() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.serverUrl.isBlank()) {
|
||||||
|
_state.value = s.copy(error = "Enter your server URL first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
||||||
|
_state.value = s.copy(loading = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val session = withContext(Dispatchers.IO) { flowClient.initiate(normalized) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
awaitingBrowserLogin = true,
|
||||||
|
browserLoginUrl = session.loginUrl
|
||||||
|
)
|
||||||
|
pollJob = launch {
|
||||||
|
val result = pollUntilDone(session)
|
||||||
|
if (result != null) {
|
||||||
|
finishBrowserLogin(result)
|
||||||
|
} else {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
awaitingBrowserLogin = false,
|
||||||
|
error = "Browser login timed out — try again"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("NCalLogin", "Failed to start browser login", e)
|
||||||
|
_state.value = _state.value.copy(loading = false, error = "Couldn't start browser login: ${describe(e)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishBrowserLogin(result: LoginFlowV2Result) {
|
||||||
|
try {
|
||||||
|
repo.login(result.server, result.loginName, result.appPassword)
|
||||||
|
repo.fullSync()
|
||||||
|
repo.scheduleBackgroundSync()
|
||||||
|
_state.value = _state.value.copy(awaitingBrowserLogin = false, success = true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("NCalLogin", "Browser login completed but sync failed", e)
|
||||||
|
_state.value = _state.value.copy(awaitingBrowserLogin = false, error = "Couldn't connect: ${describe(e)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollUntilDone(session: LoginFlowV2Session): LoginFlowV2Result? = withContext(Dispatchers.IO) {
|
||||||
|
// Nextcloud's own flow token expires after ~20 minutes; give up a little before that.
|
||||||
|
val deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(18)
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val result = try {
|
||||||
|
flowClient.poll(session)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null // transient network hiccup while polling - just keep trying until the deadline
|
||||||
|
}
|
||||||
|
if (result != null) return@withContext result
|
||||||
|
delay(2000)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the UI right after it launches the Custom Tab, so it doesn't relaunch on recomposition. */
|
||||||
|
fun browserLoginUrlConsumed() {
|
||||||
|
_state.value = _state.value.copy(browserLoginUrl = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelBrowserLogin() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
_state.value = _state.value.copy(awaitingBrowserLogin = false, browserLoginUrl = null)
|
||||||
|
}
|
||||||
|
|
||||||
/** Turns common low-level exceptions (which often have a null .message) into something readable. */
|
/** Turns common low-level exceptions (which often have a null .message) into something readable. */
|
||||||
private fun describe(e: Exception): String = when (e) {
|
private fun describe(e: Exception): String = when (e) {
|
||||||
is java.net.UnknownHostException -> "Can't resolve that host \u2014 check the server URL and your network/DNS"
|
is java.net.UnknownHostException -> "Can't resolve that host \u2014 check the server URL and your network/DNS"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:minWidth="180dp"
|
android:minWidth="180dp"
|
||||||
android:minHeight="250dp"
|
android:minHeight="110dp"
|
||||||
android:targetCellWidth="3"
|
android:targetCellWidth="3"
|
||||||
android:targetCellHeight="4"
|
android:targetCellHeight="4"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue