Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.core.auth'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:network')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -0,0 +1,102 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import java.security.MessageDigest
|
||||
|
||||
class AppLockStore(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, false)
|
||||
|
||||
fun useBiometric(): Boolean = prefs.getBoolean(KEY_BIOMETRIC, false)
|
||||
|
||||
fun shouldOfferSetup(): Boolean =
|
||||
!prefs.getBoolean(KEY_SETUP_OFFERED, false) && !isEnabled()
|
||||
|
||||
fun markSetupOffered() {
|
||||
prefs.edit().putBoolean(KEY_SETUP_OFFERED, true).apply()
|
||||
}
|
||||
|
||||
fun markBackgrounded(at: Long = System.currentTimeMillis()) {
|
||||
prefs.edit().putLong(KEY_BACKGROUND_AT, at).apply()
|
||||
}
|
||||
|
||||
fun clearBackgroundMarker() {
|
||||
prefs.edit().remove(KEY_BACKGROUND_AT).apply()
|
||||
}
|
||||
|
||||
/** Lock after the app stayed in background (process still alive) for at least [lockDelayMs]. */
|
||||
fun shouldRequireUnlock(
|
||||
now: Long = System.currentTimeMillis(),
|
||||
lockDelayMs: Long = DEFAULT_LOCK_DELAY_MS,
|
||||
): Boolean {
|
||||
if (!isEnabled()) return false
|
||||
val backgroundAt = prefs.getLong(KEY_BACKGROUND_AT, 0L)
|
||||
if (backgroundAt <= 0L) return false
|
||||
return now - backgroundAt >= lockDelayMs
|
||||
}
|
||||
|
||||
/**
|
||||
* True once per process start (app was killed / fully closed and opened again).
|
||||
* Always requires lock even if background timeout has not elapsed.
|
||||
*/
|
||||
fun consumeColdStart(): Boolean {
|
||||
if (!coldStartPending) return false
|
||||
coldStartPending = false
|
||||
return isEnabled()
|
||||
}
|
||||
|
||||
fun enable(pin: String, biometric: Boolean) {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_ENABLED, true)
|
||||
.putString(KEY_PIN_HASH, hashPin(pin))
|
||||
.putBoolean(KEY_BIOMETRIC, biometric)
|
||||
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun enableBiometricOnly() {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_ENABLED, true)
|
||||
.putBoolean(KEY_BIOMETRIC, true)
|
||||
.putBoolean(KEY_BIOMETRIC_ONLY, true)
|
||||
.remove(KEY_PIN_HASH)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||
|
||||
fun disable() {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_ENABLED, false)
|
||||
.remove(KEY_PIN_HASH)
|
||||
.putBoolean(KEY_BIOMETRIC, false)
|
||||
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun verifyPin(pin: String): Boolean {
|
||||
val stored = prefs.getString(KEY_PIN_HASH, null) ?: return false
|
||||
return stored == hashPin(pin)
|
||||
}
|
||||
|
||||
private fun hashPin(pin: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = digest.digest(pin.toByteArray(Charsets.UTF_8))
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "f7_app_lock"
|
||||
const val DEFAULT_LOCK_DELAY_MS = 60_000L
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_BACKGROUND_AT = "background_at"
|
||||
private const val KEY_BIOMETRIC = "biometric"
|
||||
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
|
||||
private const val KEY_PIN_HASH = "pin_hash"
|
||||
private const val KEY_SETUP_OFFERED = "setup_offered"
|
||||
|
||||
@Volatile
|
||||
private var coldStartPending = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
|
||||
fun Throwable.toLoginErrorMessage(): String {
|
||||
val msg = message.orEmpty()
|
||||
if (this is SSLPeerUnverifiedException ||
|
||||
this is SSLHandshakeException ||
|
||||
this is CertPathValidatorException ||
|
||||
this is SSLException ||
|
||||
msg.contains("not verified", ignoreCase = true) ||
|
||||
msg.contains("CertificateException", ignoreCase = true) ||
|
||||
msg.contains("Trust anchor", ignoreCase = true)
|
||||
) {
|
||||
return "Ошибка HTTPS: сертификат не совпадает с адресом сервера. " +
|
||||
"Проверьте URL (https://ваш-домен). " +
|
||||
"Для внутреннего dev-сервера без Let's Encrypt включите «Доверять сертификату»."
|
||||
}
|
||||
if (this is UnknownHostException) {
|
||||
return "Сервер не найден. Проверьте адрес (например https://forbion.f7cloud.ru)."
|
||||
}
|
||||
if (this is ConnectException) {
|
||||
return "Не удалось подключиться к серверу. Проверьте интернет и адрес."
|
||||
}
|
||||
if (this is SocketTimeoutException) {
|
||||
return "Сервер не отвечает (таймаут)."
|
||||
}
|
||||
if (msg.contains("401", ignoreCase = true) ||
|
||||
msg.contains("Unauthorised", ignoreCase = true) ||
|
||||
msg.contains("997", ignoreCase = true)
|
||||
) {
|
||||
return "Неверный логин или пароль. " +
|
||||
"Проверьте учётные данные. При включённой 2FA нужен пароль приложения из настроек безопасности."
|
||||
}
|
||||
if (msg.startsWith("Auth failed:")) {
|
||||
return msg.removePrefix("Auth failed: ").ifBlank { "Ошибка авторизации" }
|
||||
}
|
||||
return msg.ifBlank { "Ошибка входа" }
|
||||
}
|
||||
|
||||
fun normalizeServerUrl(raw: String): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
return when {
|
||||
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
|
||||
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
|
||||
else -> "https://$trimmed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
data class AuthSession(
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
/** Пароль учётной записи или пароль приложения (при 2FA). */
|
||||
val appPassword: String,
|
||||
/** ID для WebDAV (`/remote.php/dav/files/{id}/`), из OCS cloud/user. */
|
||||
val davUserId: String? = null,
|
||||
/** Только для тестовых серверов с самоподписанным сертификатом. */
|
||||
val trustAllCerts: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class AuthStore(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
||||
|
||||
fun save(session: AuthSession) {
|
||||
prefs.edit()
|
||||
.putString("server_url", session.serverUrl.trimEnd('/'))
|
||||
.putString("username", session.username.trim())
|
||||
.putString("app_password", session.appPassword)
|
||||
.putBoolean("trust_all_certs", session.trustAllCerts)
|
||||
.putString("dav_user_id", session.davUserId)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun load(): AuthSession? {
|
||||
val serverUrl = prefs.getString("server_url", null) ?: return null
|
||||
val username = prefs.getString("username", null) ?: return null
|
||||
val appPassword = prefs.getString("app_password", null) ?: return null
|
||||
return AuthSession(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
appPassword = appPassword,
|
||||
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||
davUserId = prefs.getString("dav_user_id", null),
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
import ru.forbion.f7cloud.core.network.isOcsSuccess
|
||||
import ru.forbion.f7cloud.core.network.ocsData
|
||||
import ru.forbion.f7cloud.core.network.ocsMeta
|
||||
import ru.forbion.f7cloud.core.network.parseJsonObject
|
||||
|
||||
object AuthVerifier {
|
||||
suspend fun verify(session: AuthSession): Result<AuthSession> {
|
||||
return runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Auth failed: HTTP ${response.code}")
|
||||
}
|
||||
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
|
||||
val meta = root.ocsMeta()
|
||||
if (!isOcsSuccess(meta)) {
|
||||
val message = meta?.optString("message").orEmpty()
|
||||
error("Auth failed: ${message.ifBlank { "HTTP ${response.code}" }}")
|
||||
}
|
||||
val userId = root.ocsData()?.optString("id").orEmpty().trim()
|
||||
session.copy(
|
||||
davUserId = userId.ifBlank { session.username },
|
||||
)
|
||||
}
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { Result.success(it) },
|
||||
onFailure = { Result.failure(Exception(it.toLoginErrorMessage(), it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import okhttp3.Request
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
import ru.forbion.f7cloud.core.network.isOcsSuccess
|
||||
import ru.forbion.f7cloud.core.network.ocsData
|
||||
import ru.forbion.f7cloud.core.network.ocsMeta
|
||||
import ru.forbion.f7cloud.core.network.parseJsonObject
|
||||
|
||||
object OcsUserResolver {
|
||||
fun resolveDavUserId(session: AuthSession): String {
|
||||
session.davUserId?.let { return it }
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("User profile HTTP ${response.code}")
|
||||
}
|
||||
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
|
||||
val meta = root.ocsMeta()
|
||||
if (!isOcsSuccess(meta)) {
|
||||
error("User profile OCS error")
|
||||
}
|
||||
val id = root.ocsData()?.optString("id").orEmpty().trim()
|
||||
return id.ifBlank { session.username }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user