Backend, sync, infra, docs: ETag, API versioning, k8s, web scaffold, Android 16, domain stubs

- Backend: ShallowEtagHeaderFilter for /api/v1/*, API-VERSIONING.md, README (tenant, CORS, Flyway, ETag)
- k8s: backend-deployment.yaml (Deployment, Service, Secret/ConfigMap)
- Web: scaffold with directory pull, 304 handling, touch-friendly UI
- Android 16: ANDROID-16-TARGET.md; BuildConfig STUN/signaling, SMOAApplication configures InfrastructureManager
- Domain: CertificateManager revocation stub, ReportService signReports, ZeroTrust/ThreatDetection minimal docs
- TODO.md and IMPLEMENTATION_STATUS.md updated; communications README for endpoint config

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 20:37:01 -08:00
parent 97f75e144f
commit 5a8c26cf5d
101 changed files with 4923 additions and 103 deletions

View File

@@ -17,6 +17,10 @@ android {
versionCode = AppConfig.versionCode
versionName = AppConfig.versionName
buildConfigField("String", "SMOA_BACKEND_BASE_URL", "\"${project.findProperty("smoa.backend.baseUrl") ?: ""}\"")
buildConfigField("String", "SMOA_API_KEY", "\"${project.findProperty("smoa.api.key") ?: ""}\"")
buildConfigField("String", "SMOA_STUN_URLS", "\"${project.findProperty("smoa.stun.urls") ?: ""}\"")
buildConfigField("String", "SMOA_SIGNALING_URLS", "\"${project.findProperty("smoa.signaling.urls") ?: ""}\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@@ -45,6 +49,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
@@ -118,6 +123,10 @@ dependencies {
// Coroutines
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
// Networking (for BackendSyncAPI)
implementation(Dependencies.retrofit)
implementation(Dependencies.retrofitGson)
implementation(Dependencies.okHttp)
// Testing
testImplementation(Dependencies.junit)

View File

@@ -5,6 +5,8 @@
<!-- Network permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Optional: improves 4G/5G/5G MW detection for smart routing (API 29+). Not required for basic connectivity. -->
<uses-permission android:name="android.permission.READ_BASIC_PHONE_STATE" />
<!-- Biometric permissions -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
@@ -45,7 +47,8 @@
android:exported="true"
android:theme="@style/Theme.SMOA"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="fullSensor">
android:screenOrientation="fullSensor"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -1,12 +1,28 @@
package com.smoa
import android.app.Application
import com.smoa.modules.communications.domain.InfrastructureManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class SMOAApplication : Application() {
@Inject
lateinit var infrastructureManager: InfrastructureManager
override fun onCreate() {
super.onCreate()
configureInfrastructure()
}
private fun configureInfrastructure() {
BuildConfig.SMOA_STUN_URLS.trim().split(",").map { it.trim() }.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
?.let { infrastructureManager.setStunEndpoints(it) }
BuildConfig.SMOA_SIGNALING_URLS.trim().split(",").map { it.trim() }.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
?.let { infrastructureManager.setSignalingEndpoints(it) }
}
}

View File

@@ -0,0 +1,68 @@
package com.smoa.api
import com.smoa.core.common.PullAPI
import com.smoa.core.common.Result
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
class BackendPullAPI(
private val apiService: BackendPullApiService,
private val apiKey: String? = null
) : PullAPI {
override suspend fun pullDirectory(unit: String?) = withContext(Dispatchers.IO) {
runCatching {
val r = apiService.getDirectory(unit, unit, apiKey)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it) }
)
}
override suspend fun pullOrders(since: Long?, limit: Int, jurisdiction: String?) = withContext(Dispatchers.IO) {
runCatching {
val r = apiService.getOrders(since, limit, jurisdiction, jurisdiction, apiKey)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it) }
)
}
override suspend fun pullEvidence(since: Long?, limit: Int, caseNumber: String?) = withContext(Dispatchers.IO) {
runCatching {
val r = apiService.getEvidence(since, limit, caseNumber, apiKey)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it) }
)
}
override suspend fun pullCredentials(since: Long?, limit: Int, holderId: String?) = withContext(Dispatchers.IO) {
runCatching {
val r = apiService.getCredentials(since, limit, holderId, apiKey)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it) }
)
}
override suspend fun pullReports(since: Long?, limit: Int) = withContext(Dispatchers.IO) {
runCatching {
val r = apiService.getReports(since, limit, apiKey)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Error(it) }
)
}
}

View File

@@ -0,0 +1,48 @@
package com.smoa.api
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface BackendPullApiService {
@GET("api/v1/directory")
suspend fun getDirectory(
@Query("unit") unit: String?,
@Header("X-Unit") xUnit: String?,
@Header("X-API-Key") apiKey: String?
): Response<ResponseBody>
@GET("api/v1/orders")
suspend fun getOrders(
@Query("since") since: Long?,
@Query("limit") limit: Int,
@Query("jurisdiction") jurisdiction: String?,
@Header("X-Unit") xUnit: String?,
@Header("X-API-Key") apiKey: String?
): Response<ResponseBody>
@GET("api/v1/evidence")
suspend fun getEvidence(
@Query("since") since: Long?,
@Query("limit") limit: Int,
@Query("caseNumber") caseNumber: String?,
@Header("X-API-Key") apiKey: String?
): Response<ResponseBody>
@GET("api/v1/credentials")
suspend fun getCredentials(
@Query("since") since: Long?,
@Query("limit") limit: Int,
@Query("holderId") holderId: String?,
@Header("X-API-Key") apiKey: String?
): Response<ResponseBody>
@GET("api/v1/reports")
suspend fun getReports(
@Query("since") since: Long?,
@Query("limit") limit: Int,
@Header("X-API-Key") apiKey: String?
): Response<ResponseBody>
}

View File

@@ -0,0 +1,163 @@
package com.smoa.api
import com.smoa.api.dto.SyncResponseDto
import com.smoa.core.common.Result
import com.smoa.core.common.SyncResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import java.util.Base64
/**
* SyncAPI implementation that calls the SMOA backend over HTTP.
* Use when BuildConfig.SMOA_BACKEND_BASE_URL is set.
*/
class BackendSyncAPI(
private val baseUrl: String,
private val apiService: BackendSyncApiService,
private val apiKey: String? = null
) : com.smoa.core.common.SyncAPI {
private val jsonType = "application/json; charset=utf-8".toMediaType()
override suspend fun syncDirectoryEntry(entryData: ByteArray): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val body = entryData.toRequestBody(jsonType)
val response = apiService.syncDirectory(body, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun syncOrder(orderData: ByteArray): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val body = orderData.toRequestBody(jsonType)
val response = apiService.syncOrder(body, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun syncEvidence(evidenceData: ByteArray): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val body = evidenceData.toRequestBody(jsonType)
val response = apiService.syncEvidence(body, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun syncCredential(credentialData: ByteArray): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val body = credentialData.toRequestBody(jsonType)
val response = apiService.syncCredential(body, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun syncReport(reportData: ByteArray): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val body = reportData.toRequestBody(jsonType)
val response = apiService.syncReport(body, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun deleteDirectory(id: String): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val response = apiService.deleteDirectory(id, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun deleteOrder(orderId: String): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val response = apiService.deleteOrder(orderId, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun deleteEvidence(evidenceId: String): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val response = apiService.deleteEvidence(evidenceId, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun deleteCredential(credentialId: String): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val response = apiService.deleteCredential(credentialId, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
override suspend fun deleteReport(reportId: String): Result<SyncResponse> =
withContext(Dispatchers.IO) {
runCatching {
val response = apiService.deleteReport(reportId, apiKey)
mapResponse(response)
}.fold(
onSuccess = { it },
onFailure = { Result.Error(it) }
)
}
private fun mapResponse(response: retrofit2.Response<SyncResponseDto>): Result<SyncResponse> {
if (!response.isSuccessful) {
return Result.Error(HttpException(response))
}
val dto = response.body() ?: return Result.Error(NullPointerException("Empty body"))
val remoteData = dto.remoteDataBase64?.let { base64 ->
try {
Base64.getDecoder().decode(base64)
} catch (_: Exception) {
null
}
}
return Result.Success(
SyncResponse(
success = dto.success,
itemId = dto.itemId,
serverTimestamp = dto.serverTimestamp,
conflict = dto.conflict,
remoteData = remoteData,
message = dto.message
)
)
}
}

View File

@@ -0,0 +1,77 @@
package com.smoa.api
import com.smoa.api.dto.SyncResponseDto
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit service for SMOA backend sync and delete endpoints.
* Base URL is set when building the Retrofit instance.
*/
interface BackendSyncApiService {
@POST("api/v1/sync/directory")
suspend fun syncDirectory(
@Body body: RequestBody,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@POST("api/v1/sync/order")
suspend fun syncOrder(
@Body body: RequestBody,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@POST("api/v1/sync/evidence")
suspend fun syncEvidence(
@Body body: RequestBody,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@POST("api/v1/sync/credential")
suspend fun syncCredential(
@Body body: RequestBody,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@POST("api/v1/sync/report")
suspend fun syncReport(
@Body body: RequestBody,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@DELETE("api/v1/sync/directory/{id}")
suspend fun deleteDirectory(
@Path("id") id: String,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@DELETE("api/v1/sync/order/{orderId}")
suspend fun deleteOrder(
@Path("orderId") orderId: String,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@DELETE("api/v1/sync/evidence/{evidenceId}")
suspend fun deleteEvidence(
@Path("evidenceId") evidenceId: String,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@DELETE("api/v1/sync/credential/{credentialId}")
suspend fun deleteCredential(
@Path("credentialId") credentialId: String,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
@DELETE("api/v1/sync/report/{reportId}")
suspend fun deleteReport(
@Path("reportId") reportId: String,
@Header("X-API-Key") apiKey: String?
): Response<SyncResponseDto>
}

View File

@@ -0,0 +1,15 @@
package com.smoa.api.dto
import com.google.gson.annotations.SerializedName
/**
* Backend sync response DTO. remoteData is base64-encoded when conflict=true.
*/
data class SyncResponseDto(
val success: Boolean,
val itemId: String,
val serverTimestamp: Long,
@SerializedName("conflict") val conflict: Boolean = false,
@SerializedName("remoteData") val remoteDataBase64: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,63 @@
package com.smoa.di
import com.smoa.api.BackendPullAPI
import com.smoa.api.BackendPullApiService
import com.smoa.api.BackendSyncAPI
import com.smoa.api.BackendSyncApiService
import com.smoa.core.common.DefaultPullAPI
import com.smoa.core.common.DefaultSyncAPI
import com.smoa.core.common.PullAPI
import com.smoa.core.common.SyncAPI
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* App-level bindings. Provides SyncAPI and PullAPI for SyncService.
* When SMOA_BACKEND_BASE_URL is set, backend implementations are used.
* Build with -Psmoa.backend.baseUrl=http://10.0.2.2:8080/ and -Psmoa.api.key=key for emulator.
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideSyncAPI(): SyncAPI {
val (baseUrl, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultSyncAPI()
val service = retrofit.create(BackendSyncApiService::class.java)
return BackendSyncAPI(baseUrl, service, apiKey = apiKey)
}
@Provides
@Singleton
fun providePullAPI(): PullAPI {
val (_, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultPullAPI()
val service = retrofit.create(BackendPullApiService::class.java)
return BackendPullAPI(service, apiKey = apiKey)
}
private fun createRetrofitIfConfigured(): Triple<String, String?, Retrofit>? {
val raw = com.smoa.BuildConfig.SMOA_BACKEND_BASE_URL.trim()
val baseUrl = if (raw.isEmpty()) null else (if (raw.endsWith("/")) raw else "$raw/")
val apiKey = com.smoa.BuildConfig.SMOA_API_KEY?.trim()?.takeIf { it.isNotEmpty() }
if (baseUrl == null) return null
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
return Triple(baseUrl, apiKey, retrofit)
}
}