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
+102
View File
@@ -0,0 +1,102 @@
# LinkedSign Android Native
Cliente Android nativo de LinkedSign construido con **Kotlin + Jetpack Compose**.
## Objetivo
- compartir autenticación con la web
- consumir el mismo backend REST
- mantener cache local en el teléfono
- sincronizar datos solo cuando el usuario lo solicite
## Qué incluye
- login
- registro
- aviso de validación por correo
- dashboard
- documentos originales y firmados
- subida de PDF
- subida de certificados `.p12/.pfx`
- firmado básico desde Android
- perfil
- solicitud de cambio de contraseña
- sesión local segura
- desbloqueo biométrico con huella si el dispositivo lo soporta
- cache local SQLite
- tipografía centralizada por variables en `ui/theme/Type.kt`
- UI Android refactorizada siguiendo la referencia visual de `android_firma_electronica.html`
## Arquitectura usada
- Kotlin nativo
- Jetpack Compose
- Retrofit
- OkHttp
- Moshi
- EncryptedSharedPreferences
- SQLiteOpenHelper
## Estructura
```text
android/
app/
build.gradle
src/main/
AndroidManifest.xml
java/net/linkedcorp/linkedsign/
res/values/
build.gradle
gradle.properties
settings.gradle
README.md
```
## Backend
Por defecto la app apunta a:
```text
http://192.168.222.72:4000
```
Ese valor está en:
[app/build.gradle](/home/mgonzalez/desarrollo/react/linkedsign/android/app/build.gradle)
Si cambias IP o dominio, actualiza:
```groovy
buildConfigField "String", "API_BASE_URL", "\"http://TU_HOST:4000\""
```
## Abrir en Android Studio
1. Abre la carpeta:
`/home/mgonzalez/desarrollo/react/linkedsign/android`
2. Deja que Android Studio sincronice Gradle
3. Configura el SDK si hace falta
4. Ejecuta en emulador o dispositivo físico
## APK generado
Se generó un build debug en:
[`android/apk/LinkedSign-debug.apk`](/home/mgonzalez/desarrollo/react/linkedsign/android/apk/LinkedSign-debug.apk)
Comando usado:
```bash
source /etc/profile.d/android-sdk.sh
cd /home/mgonzalez/desarrollo/react/linkedsign/android
GRADLE_USER_HOME=/home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-home ./gradlew assembleDebug
```
## Notas
- `android_react/` conserva la versión anterior hecha con Expo/React Native.
- `android/` es ahora la base nativa instalable.
- La app Android y la web se complementan: misma cuenta, mismo backend, cache local y sincronización manual.
- El enlace de validación por correo sigue siendo servido por el frontend web actual.
- La referencia visual principal usada para el rediseño móvil está en [`android_firma_electronica.html`](/home/mgonzalez/desarrollo/react/linkedsign/android_firma_electronica.html).
+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>
+25
View File
@@ -0,0 +1,25 @@
buildscript {
ext {
kotlin_version = '1.9.24'
compose_compiler_version = '1.5.14'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register('clean', Delete) {
delete rootProject.buildDir
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file:///home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-dist/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "LinkedSign"
include ':app'