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:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+2
View File
@@ -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 }
}
}
}