Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
|
||||
android {
|
||||
namespace 'net.linkedcorp.linkedsign'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "net.linkedcorp.linkedsign"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
|
||||
buildConfigField "String", "API_BASE_URL", "\"http://192.168.222.72:4000\""
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion compose_compiler_version
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
||||
implementation 'androidx.biometric:biometric:1.1.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6'
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# LinkedSign Android native app
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LinkedSign">
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="net.linkedcorp.linkedsign.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LinkedSign">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.linkedcorp.linkedsign
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import net.linkedcorp.linkedsign.ui.LinkedSignApp
|
||||
import net.linkedcorp.linkedsign.ui.MainViewModel
|
||||
import net.linkedcorp.linkedsign.ui.theme.LinkedSignTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
LinkedSignTheme {
|
||||
Surface(color = androidx.compose.material3.MaterialTheme.colorScheme.background) {
|
||||
val mainViewModel: MainViewModel = viewModel()
|
||||
LinkedSignApp(
|
||||
viewModel = mainViewModel,
|
||||
appContext = applicationContext,
|
||||
activity = this
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.linkedcorp.linkedsign.data
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface LinkedSignApiService {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData>
|
||||
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body body: RegisterRequest): ApiEnvelope<RegisterData>
|
||||
|
||||
@POST("auth/refresh")
|
||||
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
|
||||
|
||||
@GET("auth/me")
|
||||
suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData>
|
||||
|
||||
@PATCH("auth/change-password")
|
||||
suspend fun changePassword(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Body body: ChangePasswordRequest
|
||||
): ApiEnvelope<SimpleMessageData>
|
||||
|
||||
@GET("documents")
|
||||
suspend fun listDocuments(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Query("limit") limit: Int = 100
|
||||
): ApiEnvelope<DocumentListData>
|
||||
|
||||
@Multipart
|
||||
@POST("documents")
|
||||
suspend fun uploadDocument(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Part file: MultipartBody.Part
|
||||
): ApiEnvelope<DocumentItem>
|
||||
|
||||
@DELETE("documents/{id}")
|
||||
suspend fun deleteDocument(
|
||||
@Header("Authorization") authorization: String,
|
||||
@retrofit2.http.Path("id") id: Int
|
||||
): ApiEnvelope<SimpleMessageData>
|
||||
|
||||
@GET("certificates")
|
||||
suspend fun listCertificates(
|
||||
@Header("Authorization") authorization: String
|
||||
): ApiEnvelope<List<CertificateItem>>
|
||||
|
||||
@Multipart
|
||||
@POST("certificates")
|
||||
suspend fun uploadCertificate(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("alias") alias: RequestBody,
|
||||
@Part("password") password: RequestBody
|
||||
): ApiEnvelope<CertificateItem>
|
||||
|
||||
@GET("signatures/mine")
|
||||
suspend fun listSignedDocuments(
|
||||
@Header("Authorization") authorization: String
|
||||
): ApiEnvelope<List<SignedDocumentItem>>
|
||||
|
||||
@DELETE("signatures/{id}")
|
||||
suspend fun deleteSignedDocument(
|
||||
@Header("Authorization") authorization: String,
|
||||
@retrofit2.http.Path("id") id: Int
|
||||
): ApiEnvelope<SimpleMessageData>
|
||||
|
||||
@POST("signatures/sign")
|
||||
suspend fun signDocument(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Body body: SignRequest
|
||||
): ApiEnvelope<SignData>
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package net.linkedcorp.linkedsign.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import net.linkedcorp.linkedsign.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
class LinkedSignRepository(private val context: Context) {
|
||||
private val sessionStore = SessionStore(context)
|
||||
private val localCache = LocalCache(context)
|
||||
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
|
||||
private val service: LinkedSignApiService by lazy {
|
||||
val logger = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("${BuildConfig.API_BASE_URL.trimEnd('/')}/")
|
||||
.client(
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(logger)
|
||||
.build()
|
||||
)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(LinkedSignApiService::class.java)
|
||||
}
|
||||
|
||||
fun loadLocalSnapshot(): Pair<SessionSnapshot, CacheSnapshot> {
|
||||
return sessionStore.load() to localCache.loadSnapshot()
|
||||
}
|
||||
|
||||
suspend fun bootstrap(): Pair<SessionSnapshot, CacheSnapshot> {
|
||||
val session = sessionStore.load()
|
||||
if (session.accessToken.isNullOrBlank()) {
|
||||
return session to localCache.loadSnapshot()
|
||||
}
|
||||
|
||||
return try {
|
||||
val user = withAuth { auth -> unwrap(service.me(auth)).user }
|
||||
val updated = session.copy(user = user)
|
||||
sessionStore.save(updated)
|
||||
localCache.saveProfile(user)
|
||||
updated to localCache.loadSnapshot()
|
||||
} catch (_: Exception) {
|
||||
sessionStore.clearSession()
|
||||
SessionSnapshot() to localCache.loadSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(email: String, password: String): SessionSnapshot {
|
||||
return execute {
|
||||
val response = unwrap(service.login(LoginRequest(email.trim(), password)))
|
||||
val session = SessionSnapshot(
|
||||
accessToken = response.accessToken,
|
||||
refreshToken = response.refreshToken,
|
||||
user = response.user
|
||||
)
|
||||
sessionStore.save(session)
|
||||
localCache.saveProfile(response.user)
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(email: String, password: String): RegisterData {
|
||||
return execute {
|
||||
unwrap(service.register(RegisterRequest(email.trim(), password)))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
execute {
|
||||
val session = sessionStore.load()
|
||||
try {
|
||||
if (!session.refreshToken.isNullOrBlank()) {
|
||||
unwrap(service.logout(RefreshRequest(session.refreshToken)))
|
||||
}
|
||||
} finally {
|
||||
sessionStore.clearSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun biometricLogin(): SessionSnapshot {
|
||||
val credentials = sessionStore.loadBiometricCredentials()
|
||||
?: throw AppException("No hay credenciales biométricas guardadas.")
|
||||
return login(credentials.first, credentials.second)
|
||||
}
|
||||
|
||||
fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
|
||||
|
||||
fun enableBiometricAccess(email: String, password: String) {
|
||||
sessionStore.saveBiometricAccess(email.trim(), password)
|
||||
}
|
||||
|
||||
fun declineBiometricAccess() {
|
||||
sessionStore.declineBiometricAccess()
|
||||
}
|
||||
|
||||
fun syncBiometricCredentials(email: String, password: String) {
|
||||
sessionStore.updateBiometricCredentials(email.trim(), password)
|
||||
}
|
||||
|
||||
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
|
||||
execute { withAuth { auth ->
|
||||
unwrap(service.changePassword(auth, ChangePasswordRequest(currentPassword, newPassword)))
|
||||
} }
|
||||
|
||||
suspend fun syncAll(): CacheSnapshot = execute { withAuth { auth ->
|
||||
val documents = unwrap(service.listDocuments(auth)).rows
|
||||
val certificates = unwrap(service.listCertificates(auth))
|
||||
val signedDocuments = unwrap(service.listSignedDocuments(auth))
|
||||
val user = unwrap(service.me(auth)).user
|
||||
|
||||
localCache.saveProfile(user)
|
||||
localCache.replaceDocuments(documents)
|
||||
localCache.replaceCertificates(certificates)
|
||||
localCache.replaceSignedDocuments(signedDocuments)
|
||||
localCache.setMeta("last_sync_at", java.time.OffsetDateTime.now().toString())
|
||||
|
||||
localCache.loadSnapshot()
|
||||
} }
|
||||
|
||||
suspend fun uploadDocument(uri: Uri, fileName: String): DocumentItem = execute { withAuth { auth ->
|
||||
val part = withContext(Dispatchers.IO) {
|
||||
createFilePart(uri, fileName, "application/pdf", "file")
|
||||
}
|
||||
val item = unwrap(service.uploadDocument(auth, part))
|
||||
syncAll()
|
||||
item
|
||||
} }
|
||||
|
||||
suspend fun uploadCertificate(uri: Uri, fileName: String, alias: String, password: String): CertificateItem = execute { withAuth { auth ->
|
||||
val part = withContext(Dispatchers.IO) {
|
||||
createFilePart(uri, fileName, "application/octet-stream", "file")
|
||||
}
|
||||
val aliasBody = alias.toRequestBody("text/plain".toMediaType())
|
||||
val passwordBody = password.toRequestBody("text/plain".toMediaType())
|
||||
val item = unwrap(service.uploadCertificate(auth, part, aliasBody, passwordBody))
|
||||
syncAll()
|
||||
item
|
||||
} }
|
||||
|
||||
suspend fun deleteDocument(documentId: Int): SimpleMessageData = execute { withAuth { auth ->
|
||||
val result = unwrap(service.deleteDocument(auth, documentId))
|
||||
syncAll()
|
||||
result
|
||||
} }
|
||||
|
||||
suspend fun deleteSignedDocument(signatureId: Int): SimpleMessageData = execute { withAuth { auth ->
|
||||
val result = unwrap(service.deleteSignedDocument(auth, signatureId))
|
||||
syncAll()
|
||||
result
|
||||
} }
|
||||
|
||||
suspend fun signDocument(request: SignRequest): SignData = execute { withAuth { auth ->
|
||||
val result = unwrap(service.signDocument(auth, request))
|
||||
syncAll()
|
||||
result
|
||||
} }
|
||||
|
||||
fun getAccessToken(): String? = sessionStore.load().accessToken
|
||||
|
||||
private suspend fun <T> withAuth(block: suspend (String) -> T): T {
|
||||
val session = sessionStore.load()
|
||||
val accessToken = session.accessToken ?: throw AppException("Debes iniciar sesión.")
|
||||
|
||||
return try {
|
||||
block("Bearer $accessToken")
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw mapHttpException(error)
|
||||
|
||||
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.")
|
||||
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
val updated = session.copy(
|
||||
accessToken = refresh.accessToken,
|
||||
refreshToken = refresh.refreshToken
|
||||
)
|
||||
sessionStore.save(updated)
|
||||
block("Bearer ${refresh.accessToken}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> execute(block: suspend () -> T): T {
|
||||
return try {
|
||||
block()
|
||||
} catch (error: AppException) {
|
||||
throw error
|
||||
} catch (error: HttpException) {
|
||||
throw mapHttpException(error)
|
||||
} catch (_: IOException) {
|
||||
throw AppException("No se pudo conectar con el servidor. Verifica la IP, el puerto y que el backend esté activo.")
|
||||
} catch (error: Exception) {
|
||||
throw AppException(error.message ?: "La operación no pudo completarse (${error::class.java.simpleName}).")
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> unwrap(response: ApiEnvelope<T>): T {
|
||||
if (!response.success || response.data == null) {
|
||||
throw AppException(response.error ?: "La operación no pudo completarse.")
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
private fun mapHttpException(error: HttpException): AppException {
|
||||
val raw = error.response()?.errorBody()?.string()
|
||||
if (raw.isNullOrBlank()) {
|
||||
return AppException("Error de comunicación con el servidor.")
|
||||
}
|
||||
|
||||
return try {
|
||||
val adapter = moshi.adapter(ErrorEnvelope::class.java)
|
||||
val parsed = adapter.fromJson(raw)
|
||||
AppException(
|
||||
message = parsed?.error ?: "Error de comunicación con el servidor.",
|
||||
code = parsed?.code
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
AppException(raw)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFilePart(uri: Uri, fileName: String, mimeType: String, fieldName: String): MultipartBody.Part {
|
||||
val safeFileName = fileName.ifBlank { "documento.pdf" }
|
||||
val extension = safeFileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
||||
val suffix = if (extension.isNotBlank()) ".${extension.take(10)}" else ".bin"
|
||||
|
||||
val tempFile = try {
|
||||
File.createTempFile("linkedsign-upload-", suffix, context.cacheDir)
|
||||
} catch (error: Exception) {
|
||||
throw AppException("No se pudo preparar el archivo para subirlo.")
|
||||
}
|
||||
|
||||
try {
|
||||
when {
|
||||
uri.scheme.equals("file", ignoreCase = true) -> {
|
||||
val sourcePath = uri.path ?: throw AppException("No se pudo resolver el archivo seleccionado.")
|
||||
val sourceFile = File(sourcePath)
|
||||
if (!sourceFile.exists() || !sourceFile.canRead()) {
|
||||
throw AppException("No se pudo leer el archivo seleccionado.")
|
||||
}
|
||||
sourceFile.inputStream().use { input ->
|
||||
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
var copied = false
|
||||
|
||||
try {
|
||||
val descriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
if (descriptor != null) {
|
||||
descriptor.use { pfd ->
|
||||
FileInputStream(pfd.fileDescriptor).use { input ->
|
||||
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
copied = true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw AppException("No se pudo leer el archivo seleccionado.")
|
||||
inputStream.use { input ->
|
||||
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: SecurityException) {
|
||||
throw AppException("Android perdió acceso al archivo. Intenta seleccionarlo otra vez.")
|
||||
} catch (error: IOException) {
|
||||
throw AppException("No se pudo copiar el archivo seleccionado.")
|
||||
} catch (error: AppException) {
|
||||
throw error
|
||||
}
|
||||
|
||||
val requestBody = tempFile.asRequestBody(mimeType.toMediaType())
|
||||
return MultipartBody.Part.createFormData(fieldName, safeFileName, requestBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package net.linkedcorp.linkedsign.data
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
private class LinkedSignDbHelper(context: Context) :
|
||||
SQLiteOpenHelper(context, "linkedsign-mobile.db", null, 1) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sync_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS profile_cache (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents_cache (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS certificates_cache (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS signed_documents_cache (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||
}
|
||||
|
||||
class LocalCache(context: Context) {
|
||||
private val dbHelper = LinkedSignDbHelper(context)
|
||||
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
private val userAdapter = moshi.adapter(SessionUser::class.java)
|
||||
private val documentAdapter = moshi.adapter(DocumentItem::class.java)
|
||||
private val certificateAdapter = moshi.adapter(CertificateItem::class.java)
|
||||
private val signedAdapter = moshi.adapter(SignedDocumentItem::class.java)
|
||||
|
||||
fun loadSnapshot(): CacheSnapshot {
|
||||
val db = dbHelper.readableDatabase
|
||||
return CacheSnapshot(
|
||||
user = querySinglePayload(db, "profile_cache", userAdapter),
|
||||
documents = queryAllPayloads(db, "documents_cache", documentAdapter),
|
||||
certificates = queryAllPayloads(db, "certificates_cache", certificateAdapter),
|
||||
signedDocuments = queryAllPayloads(db, "signed_documents_cache", signedAdapter),
|
||||
lastSyncAt = getMeta(db, "last_sync_at")
|
||||
)
|
||||
}
|
||||
|
||||
fun saveProfile(user: SessionUser?) {
|
||||
val db = dbHelper.writableDatabase
|
||||
db.delete("profile_cache", null, null)
|
||||
user ?: return
|
||||
val values = ContentValues().apply {
|
||||
put("id", user.id)
|
||||
put("payload", userAdapter.toJson(user))
|
||||
}
|
||||
db.insert("profile_cache", null, values)
|
||||
}
|
||||
|
||||
fun replaceDocuments(rows: List<DocumentItem>) = replaceTable("documents_cache", rows, documentAdapter) { it.id }
|
||||
|
||||
fun replaceCertificates(rows: List<CertificateItem>) = replaceTable("certificates_cache", rows, certificateAdapter) { it.id }
|
||||
|
||||
fun replaceSignedDocuments(rows: List<SignedDocumentItem>) = replaceTable("signed_documents_cache", rows, signedAdapter) { it.id }
|
||||
|
||||
fun setMeta(key: String, value: String) {
|
||||
val db = dbHelper.writableDatabase
|
||||
val values = ContentValues().apply {
|
||||
put("key", key)
|
||||
put("value", value)
|
||||
}
|
||||
db.insertWithOnConflict("sync_meta", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
private fun <T> replaceTable(
|
||||
table: String,
|
||||
rows: List<T>,
|
||||
adapter: com.squareup.moshi.JsonAdapter<T>,
|
||||
idSelector: (T) -> Int
|
||||
) {
|
||||
val db = dbHelper.writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
db.delete(table, null, null)
|
||||
rows.forEach { row ->
|
||||
val values = ContentValues().apply {
|
||||
put("id", idSelector(row))
|
||||
put("payload", adapter.toJson(row))
|
||||
}
|
||||
db.insert(table, null, values)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMeta(db: SQLiteDatabase, key: String): String? =
|
||||
db.rawQuery("SELECT value FROM sync_meta WHERE key = ?", arrayOf(key)).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
|
||||
private fun <T> querySinglePayload(
|
||||
db: SQLiteDatabase,
|
||||
table: String,
|
||||
adapter: com.squareup.moshi.JsonAdapter<T>
|
||||
): T? = db.rawQuery("SELECT payload FROM $table LIMIT 1", null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
adapter.fromJson(cursor.getString(0))
|
||||
}
|
||||
|
||||
private fun <T> queryAllPayloads(
|
||||
db: SQLiteDatabase,
|
||||
table: String,
|
||||
adapter: com.squareup.moshi.JsonAdapter<T>
|
||||
): List<T> = db.rawQuery("SELECT payload FROM $table ORDER BY id DESC", null).use { cursor ->
|
||||
val result = mutableListOf<T>()
|
||||
while (cursor.moveToNext()) {
|
||||
adapter.fromJson(cursor.getString(0))?.let(result::add)
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package net.linkedcorp.linkedsign.data
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
|
||||
data class ApiEnvelope<T>(
|
||||
val success: Boolean,
|
||||
val data: T?,
|
||||
val error: String?
|
||||
)
|
||||
|
||||
data class ErrorEnvelope(
|
||||
val success: Boolean? = null,
|
||||
val data: Any? = null,
|
||||
val error: String? = null,
|
||||
val code: String? = null
|
||||
)
|
||||
|
||||
data class SessionUser(
|
||||
val id: Int,
|
||||
val name: String?,
|
||||
val email: String,
|
||||
val role: String?,
|
||||
@Json(name = "signaturePreferences")
|
||||
val signaturePreferences: SignaturePreferences? = null
|
||||
)
|
||||
|
||||
data class SignaturePreferences(
|
||||
val signatureStyle: String? = "qr",
|
||||
val qrPosition: String? = "right",
|
||||
val qrBackground: String? = "transparent",
|
||||
val qrBorder: String? = "none",
|
||||
val signatureImageBase64: String? = null
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class LoginData(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val user: SessionUser
|
||||
)
|
||||
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class RegisterData(
|
||||
val message: String?,
|
||||
val userId: Int?
|
||||
)
|
||||
|
||||
data class RefreshRequest(
|
||||
val refreshToken: String
|
||||
)
|
||||
|
||||
data class RefreshData(
|
||||
val accessToken: String,
|
||||
val refreshToken: String
|
||||
)
|
||||
|
||||
data class SimpleMessageData(
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class MeData(
|
||||
val user: SessionUser
|
||||
)
|
||||
|
||||
data class ChangePasswordRequest(
|
||||
val currentPassword: String,
|
||||
val newPassword: String
|
||||
)
|
||||
|
||||
data class DocumentListData(
|
||||
val rows: List<DocumentItem> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int = 0,
|
||||
val offset: Int = 0
|
||||
)
|
||||
|
||||
data class DocumentItem(
|
||||
val id: Int,
|
||||
val title: String?,
|
||||
val filename: String?,
|
||||
val status: String?,
|
||||
@Json(name = "created_at")
|
||||
val createdAt: String?,
|
||||
@Json(name = "file_size")
|
||||
val fileSize: Long?
|
||||
)
|
||||
|
||||
data class CertificateItem(
|
||||
val id: Int,
|
||||
val alias: String?,
|
||||
@Json(name = "subject_cn")
|
||||
val subjectCn: String?,
|
||||
@Json(name = "issuer_cn")
|
||||
val issuerCn: String?,
|
||||
@Json(name = "valid_from")
|
||||
val validFrom: String?,
|
||||
@Json(name = "valid_to")
|
||||
val validTo: String?,
|
||||
@Json(name = "is_active")
|
||||
val isActive: Int?,
|
||||
@Json(name = "has_password")
|
||||
val hasPassword: Int?,
|
||||
@Json(name = "certificate_count")
|
||||
val certificateCount: Int?,
|
||||
@Json(name = "chain_status")
|
||||
val chainStatus: String?
|
||||
)
|
||||
|
||||
data class SignedDocumentItem(
|
||||
val id: Int,
|
||||
@Json(name = "document_id")
|
||||
val documentId: Int?,
|
||||
@Json(name = "signed_filename")
|
||||
val signedFilename: String?,
|
||||
@Json(name = "document_title")
|
||||
val documentTitle: String?,
|
||||
@Json(name = "certificate_alias")
|
||||
val certificateAlias: String?,
|
||||
@Json(name = "signed_at")
|
||||
val signedAt: String?
|
||||
)
|
||||
|
||||
data class SignRequest(
|
||||
val documentId: Int,
|
||||
val certificateId: Int,
|
||||
val certPassword: String?,
|
||||
val reason: String?,
|
||||
val location: String?,
|
||||
val page: Int = 0,
|
||||
val rect: SignatureRect? = null,
|
||||
val signatureStyle: String?,
|
||||
val qrPosition: String?,
|
||||
val qrBackground: String?,
|
||||
val qrBorder: String?,
|
||||
val signatureImageBase64: String? = null
|
||||
)
|
||||
|
||||
data class SignatureRect(
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
|
||||
data class SignData(
|
||||
val sigId: Int?,
|
||||
val signedFilename: String?,
|
||||
val documentId: Int?,
|
||||
val verificationCode: String?,
|
||||
val verificationUrl: String?
|
||||
)
|
||||
|
||||
data class SessionSnapshot(
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val user: SessionUser? = null
|
||||
)
|
||||
|
||||
data class BiometricAccessState(
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val email: String? = null,
|
||||
val hasCredentials: Boolean = false
|
||||
)
|
||||
|
||||
data class CacheSnapshot(
|
||||
val user: SessionUser? = null,
|
||||
val documents: List<DocumentItem> = emptyList(),
|
||||
val certificates: List<CertificateItem> = emptyList(),
|
||||
val signedDocuments: List<SignedDocumentItem> = emptyList(),
|
||||
val lastSyncAt: String? = null
|
||||
)
|
||||
|
||||
class AppException(
|
||||
override val message: String,
|
||||
val code: String? = null
|
||||
) : Exception(message)
|
||||
@@ -0,0 +1,114 @@
|
||||
package net.linkedcorp.linkedsign.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
class SessionStore(context: Context) {
|
||||
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
private val userAdapter = moshi.adapter(SessionUser::class.java)
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val preferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"linkedsign_secure_session",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun load(): SessionSnapshot {
|
||||
val accessToken = preferences.getString(KEY_ACCESS, null)
|
||||
val refreshToken = preferences.getString(KEY_REFRESH, null)
|
||||
val userJson = preferences.getString(KEY_USER, null)
|
||||
val user = userJson?.let { userAdapter.fromJson(it) }
|
||||
return SessionSnapshot(accessToken, refreshToken, user)
|
||||
}
|
||||
|
||||
fun save(session: SessionSnapshot) {
|
||||
preferences.edit()
|
||||
.putString(KEY_ACCESS, session.accessToken)
|
||||
.putString(KEY_REFRESH, session.refreshToken)
|
||||
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit().clear().apply()
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
preferences.edit()
|
||||
.remove(KEY_ACCESS)
|
||||
.remove(KEY_REFRESH)
|
||||
.remove(KEY_USER)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBiometricState(): BiometricAccessState {
|
||||
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
|
||||
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
|
||||
val email = preferences.getString(KEY_BIO_EMAIL, null)
|
||||
val password = preferences.getString(KEY_BIO_PASSWORD, null)
|
||||
|
||||
return BiometricAccessState(
|
||||
enabled = enabled,
|
||||
configured = configured,
|
||||
email = email,
|
||||
hasCredentials = !email.isNullOrBlank() && !password.isNullOrBlank()
|
||||
)
|
||||
}
|
||||
|
||||
fun saveBiometricAccess(email: String, password: String) {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_BIO_ENABLED, true)
|
||||
.putBoolean(KEY_BIO_CONFIGURED, true)
|
||||
.putString(KEY_BIO_EMAIL, email)
|
||||
.putString(KEY_BIO_PASSWORD, password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun declineBiometricAccess() {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_BIO_ENABLED, false)
|
||||
.putBoolean(KEY_BIO_CONFIGURED, true)
|
||||
.remove(KEY_BIO_EMAIL)
|
||||
.remove(KEY_BIO_PASSWORD)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun updateBiometricCredentials(email: String, password: String) {
|
||||
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
preferences.edit()
|
||||
.putString(KEY_BIO_EMAIL, email)
|
||||
.putString(KEY_BIO_PASSWORD, password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBiometricCredentials(): Pair<String, String>? {
|
||||
val email = preferences.getString(KEY_BIO_EMAIL, null)
|
||||
val password = preferences.getString(KEY_BIO_PASSWORD, null)
|
||||
if (email.isNullOrBlank() || password.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return email to password
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_ACCESS = "access_token"
|
||||
private const val KEY_REFRESH = "refresh_token"
|
||||
private const val KEY_USER = "user"
|
||||
private const val KEY_BIO_ENABLED = "bio_enabled"
|
||||
private const val KEY_BIO_CONFIGURED = "bio_configured"
|
||||
private const val KEY_BIO_EMAIL = "bio_email"
|
||||
private const val KEY_BIO_PASSWORD = "bio_password"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
package net.linkedcorp.linkedsign.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import net.linkedcorp.linkedsign.data.AppException
|
||||
import net.linkedcorp.linkedsign.data.CacheSnapshot
|
||||
import net.linkedcorp.linkedsign.data.LinkedSignRepository
|
||||
import net.linkedcorp.linkedsign.data.SessionSnapshot
|
||||
import net.linkedcorp.linkedsign.data.SignRequest
|
||||
import net.linkedcorp.linkedsign.data.SignatureRect
|
||||
import net.linkedcorp.linkedsign.data.SignaturePreferences
|
||||
|
||||
data class MainUiState(
|
||||
val loading: Boolean = true,
|
||||
val authenticated: Boolean = false,
|
||||
val biometricEnabled: Boolean = false,
|
||||
val biometricConfigured: Boolean = false,
|
||||
val biometricEmail: String? = null,
|
||||
val biometricHasCredentials: Boolean = false,
|
||||
val emailForVerificationNotice: String? = null,
|
||||
val userName: String? = null,
|
||||
val userEmail: String? = null,
|
||||
val role: String? = null,
|
||||
val signaturePreferences: SignaturePreferences? = null,
|
||||
val accessToken: String? = null,
|
||||
val documents: List<net.linkedcorp.linkedsign.data.DocumentItem> = emptyList(),
|
||||
val certificates: List<net.linkedcorp.linkedsign.data.CertificateItem> = emptyList(),
|
||||
val signedDocuments: List<net.linkedcorp.linkedsign.data.SignedDocumentItem> = emptyList(),
|
||||
val lastSyncAt: String? = null,
|
||||
val syncing: Boolean = false
|
||||
)
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = LinkedSignRepository(application.applicationContext)
|
||||
|
||||
var uiState by mutableStateOf(MainUiState())
|
||||
private set
|
||||
|
||||
init {
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = true)
|
||||
}
|
||||
|
||||
suspend fun bootstrap() {
|
||||
val (session, cache) = repository.bootstrap()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
suspend fun login(email: String, password: String) {
|
||||
val session = repository.login(email, password)
|
||||
repository.syncBiometricCredentials(email, password)
|
||||
val (_, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
suspend fun biometricLogin() {
|
||||
val session = repository.biometricLogin()
|
||||
val (_, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
suspend fun register(email: String, password: String) {
|
||||
repository.register(email, password)
|
||||
uiState = uiState.copy(emailForVerificationNotice = email)
|
||||
}
|
||||
|
||||
fun clearVerificationNotice() {
|
||||
uiState = uiState.copy(emailForVerificationNotice = null)
|
||||
}
|
||||
|
||||
suspend fun syncNow() {
|
||||
uiState = uiState.copy(syncing = true)
|
||||
try {
|
||||
val cache = repository.syncAll()
|
||||
val session = repository.loadLocalSnapshot().first
|
||||
uiState = buildState(session, cache, loading = false, syncing = false)
|
||||
} catch (error: Exception) {
|
||||
uiState = uiState.copy(syncing = false)
|
||||
throw normalize(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadDocument(uri: Uri, fileName: String) {
|
||||
repository.uploadDocument(uri, fileName)
|
||||
syncNow()
|
||||
}
|
||||
|
||||
suspend fun uploadCertificate(uri: Uri, fileName: String, alias: String, password: String) {
|
||||
repository.uploadCertificate(uri, fileName, alias, password)
|
||||
syncNow()
|
||||
}
|
||||
|
||||
suspend fun deleteDocument(documentId: Int) {
|
||||
repository.deleteDocument(documentId)
|
||||
syncNow()
|
||||
}
|
||||
|
||||
suspend fun deleteSignedDocument(signatureId: Int) {
|
||||
repository.deleteSignedDocument(signatureId)
|
||||
syncNow()
|
||||
}
|
||||
|
||||
suspend fun signDocument(
|
||||
documentId: Int,
|
||||
certificateId: Int,
|
||||
certPassword: String,
|
||||
reason: String,
|
||||
location: String,
|
||||
page: Int,
|
||||
rect: SignatureRect?
|
||||
) {
|
||||
val preferences = repository.loadLocalSnapshot().first.user?.signaturePreferences
|
||||
repository.signDocument(
|
||||
SignRequest(
|
||||
documentId = documentId,
|
||||
certificateId = certificateId,
|
||||
certPassword = certPassword,
|
||||
reason = reason,
|
||||
location = location,
|
||||
page = page,
|
||||
rect = rect,
|
||||
signatureStyle = preferences?.signatureStyle ?: "qr",
|
||||
qrPosition = preferences?.qrPosition ?: "right",
|
||||
qrBackground = preferences?.qrBackground ?: "transparent",
|
||||
qrBorder = preferences?.qrBorder ?: "none",
|
||||
signatureImageBase64 = preferences?.signatureImageBase64
|
||||
)
|
||||
)
|
||||
syncNow()
|
||||
}
|
||||
|
||||
suspend fun requestPasswordChange(currentPassword: String, newPassword: String) {
|
||||
repository.requestPasswordChange(currentPassword, newPassword)
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
repository.logout()
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
fun accessToken(): String? = repository.getAccessToken()
|
||||
|
||||
fun enableBiometricAccess(email: String, password: String) {
|
||||
repository.enableBiometricAccess(email, password)
|
||||
refreshState()
|
||||
}
|
||||
|
||||
fun declineBiometricAccess() {
|
||||
repository.declineBiometricAccess()
|
||||
refreshState()
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
session: SessionSnapshot,
|
||||
cache: CacheSnapshot,
|
||||
loading: Boolean,
|
||||
syncing: Boolean = false
|
||||
): MainUiState {
|
||||
val user = session.user ?: cache.user
|
||||
val biometric = repository.biometricState()
|
||||
return MainUiState(
|
||||
loading = loading,
|
||||
syncing = syncing,
|
||||
authenticated = !session.accessToken.isNullOrBlank() && user != null,
|
||||
biometricEnabled = biometric.enabled,
|
||||
biometricConfigured = biometric.configured,
|
||||
biometricEmail = biometric.email,
|
||||
biometricHasCredentials = biometric.hasCredentials,
|
||||
emailForVerificationNotice = uiState.emailForVerificationNotice,
|
||||
userName = user?.name,
|
||||
userEmail = user?.email,
|
||||
role = user?.role,
|
||||
signaturePreferences = user?.signaturePreferences,
|
||||
accessToken = session.accessToken,
|
||||
documents = cache.documents,
|
||||
certificates = cache.certificates,
|
||||
signedDocuments = cache.signedDocuments,
|
||||
lastSyncAt = cache.lastSyncAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshState() {
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
|
||||
}
|
||||
|
||||
private fun normalize(error: Exception): AppException =
|
||||
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.linkedcorp.linkedsign.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val LinkedSignPrimary = Color(0xFF4F46E5)
|
||||
val LinkedSignOnPrimary = Color(0xFFFFFFFF)
|
||||
val LinkedSignPrimaryContainer = Color(0xFFE0E7FF)
|
||||
val LinkedSignOnPrimaryContainer = Color(0xFF1E1B4B)
|
||||
|
||||
val LinkedSignSecondary = Color(0xFF111827)
|
||||
val LinkedSignOnSecondary = Color(0xFFFFFFFF)
|
||||
val LinkedSignSecondaryContainer = Color(0xFFE5E7EB)
|
||||
val LinkedSignOnSecondaryContainer = Color(0xFF111827)
|
||||
|
||||
val LinkedSignTertiary = Color(0xFF0F766E)
|
||||
val LinkedSignOnTertiary = Color(0xFFFFFFFF)
|
||||
|
||||
val LinkedSignBackground = Color(0xFFF3F4F6)
|
||||
val LinkedSignOnBackground = Color(0xFF111827)
|
||||
val LinkedSignSurface = Color(0xFFFFFFFF)
|
||||
val LinkedSignOnSurface = Color(0xFF111827)
|
||||
val LinkedSignSurfaceVariant = Color(0xFFF8FAFC)
|
||||
val LinkedSignOnSurfaceVariant = Color(0xFF4B5563)
|
||||
|
||||
val LinkedSignOutline = Color(0xFFD1D5DB)
|
||||
val LinkedSignError = Color(0xFFB91C1C)
|
||||
val LinkedSignOnError = Color(0xFFFFFFFF)
|
||||
@@ -0,0 +1,107 @@
|
||||
package net.linkedcorp.linkedsign.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LinkedSignLightColorScheme = lightColorScheme(
|
||||
primary = LinkedSignPrimary,
|
||||
onPrimary = LinkedSignOnPrimary,
|
||||
primaryContainer = LinkedSignPrimaryContainer,
|
||||
onPrimaryContainer = LinkedSignOnPrimaryContainer,
|
||||
secondary = LinkedSignSecondary,
|
||||
onSecondary = LinkedSignOnSecondary,
|
||||
secondaryContainer = LinkedSignSecondaryContainer,
|
||||
onSecondaryContainer = LinkedSignOnSecondaryContainer,
|
||||
tertiary = LinkedSignTertiary,
|
||||
onTertiary = LinkedSignOnTertiary,
|
||||
background = LinkedSignBackground,
|
||||
onBackground = LinkedSignOnBackground,
|
||||
surface = LinkedSignSurface,
|
||||
onSurface = LinkedSignOnSurface,
|
||||
surfaceVariant = LinkedSignSurfaceVariant,
|
||||
onSurfaceVariant = LinkedSignOnSurfaceVariant,
|
||||
outline = LinkedSignOutline,
|
||||
error = LinkedSignError,
|
||||
onError = LinkedSignOnError
|
||||
)
|
||||
|
||||
private val LinkedSignDarkColorScheme = darkColorScheme(
|
||||
primary = LinkedSignPrimaryContainer,
|
||||
onPrimary = LinkedSignOnPrimaryContainer,
|
||||
primaryContainer = LinkedSignPrimary,
|
||||
onPrimaryContainer = LinkedSignOnPrimary,
|
||||
secondary = ColorTokens.DarkSecondary,
|
||||
onSecondary = LinkedSignOnPrimary,
|
||||
secondaryContainer = ColorTokens.DarkSecondaryContainer,
|
||||
onSecondaryContainer = LinkedSignOnPrimary,
|
||||
tertiary = ColorTokens.DarkTertiary,
|
||||
onTertiary = LinkedSignOnPrimary,
|
||||
background = ColorTokens.DarkBackground,
|
||||
onBackground = ColorTokens.DarkOnBackground,
|
||||
surface = ColorTokens.DarkSurface,
|
||||
onSurface = ColorTokens.DarkOnSurface,
|
||||
surfaceVariant = ColorTokens.DarkSurfaceVariant,
|
||||
onSurfaceVariant = ColorTokens.DarkOnSurfaceVariant,
|
||||
outline = ColorTokens.DarkOutline,
|
||||
error = LinkedSignError,
|
||||
onError = LinkedSignOnError
|
||||
)
|
||||
|
||||
private object ColorTokens {
|
||||
val DarkSecondary = androidx.compose.ui.graphics.Color(0xFF1F2937)
|
||||
val DarkSecondaryContainer = androidx.compose.ui.graphics.Color(0xFF374151)
|
||||
val DarkTertiary = androidx.compose.ui.graphics.Color(0xFF14B8A6)
|
||||
val DarkBackground = androidx.compose.ui.graphics.Color(0xFF030712)
|
||||
val DarkOnBackground = androidx.compose.ui.graphics.Color(0xFFF9FAFB)
|
||||
val DarkSurface = androidx.compose.ui.graphics.Color(0xFF111827)
|
||||
val DarkOnSurface = androidx.compose.ui.graphics.Color(0xFFF9FAFB)
|
||||
val DarkSurfaceVariant = androidx.compose.ui.graphics.Color(0xFF1F2937)
|
||||
val DarkOnSurfaceVariant = androidx.compose.ui.graphics.Color(0xFFD1D5DB)
|
||||
val DarkOutline = androidx.compose.ui.graphics.Color(0xFF4B5563)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LinkedSignTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> LinkedSignDarkColorScheme
|
||||
else -> LinkedSignLightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.surface.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.linkedcorp.linkedsign.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object AppFontSizes {
|
||||
val xxs: TextUnit = 9.sp
|
||||
val xs: TextUnit = 10.sp
|
||||
val sm: TextUnit = 11.sp
|
||||
val md: TextUnit = 12.sp
|
||||
val base: TextUnit = 13.sp
|
||||
val lg: TextUnit = 14.sp
|
||||
val xl: TextUnit = 15.sp
|
||||
val title: TextUnit = 18.sp
|
||||
val hero: TextUnit = 22.sp
|
||||
val brand: TextUnit = 24.sp
|
||||
val display: TextUnit = 28.sp
|
||||
}
|
||||
|
||||
val Typography = Typography(
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = AppFontSizes.display,
|
||||
lineHeight = 34.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = AppFontSizes.lg,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = AppFontSizes.base,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = AppFontSizes.md,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = AppFontSizes.base,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = AppFontSizes.md,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = AppFontSizes.sm,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,18c-11.2,8.4 -22.9,12.6 -35,12.6v21.8c0,24.5 14.8,34.4 35,40.6c20.2,-6.2 35,-16.1 35,-40.6V30.6C76.9,30.6 65.2,26.4 54,18z" />
|
||||
<path
|
||||
android:fillColor="#4F46E5"
|
||||
android:pathData="M40,38h12c7.9,0 14,5.3 14,12.5S59.9,63 52,63h-4v8h-8V38zm8,7v11h3.7c3.7,0 6.1,-2.1 6.1,-5.5c0,-3.5 -2.4,-5.5 -6.1,-5.5H48z" />
|
||||
<path
|
||||
android:fillColor="#0F172A"
|
||||
android:pathData="M38,76h32v4H38z" />
|
||||
</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/ic_launcher_background" />
|
||||
<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/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<color name="purple_primary">#4F46E5</color>
|
||||
<color name="dark_surface">#111827</color>
|
||||
<color name="background_soft">#F3F4F6</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#111827</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">LinkedSign</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.LinkedSign" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@color/background_soft</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="cache_files"
|
||||
path="." />
|
||||
<files-path
|
||||
name="files"
|
||||
path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user