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