Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
+78
View File
@@ -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'
}
+1
View File
@@ -0,0 +1 @@
# LinkedSign Android native app
+34
View File
@@ -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>