Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
app/build/outputs/apk/debug
app/build/outputs/apk/release
app/build/outputs/bundle/release
app/build/outputs/mapping/release
validation/native-packaging.json
app/build/generated/brand-fonts.json
ui:
Expand Down
2 changes: 1 addition & 1 deletion api/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# API contract provenance

`openapi.json` is an unchanged, owner-supplied OpenAPI 3.0.3 snapshot from `SpaceWorks-HQ/SpaceWorks`, `frontend/openapi-schema.json`, source commit `e7c2803a4a57870e11dc4faf9adc40ee54401632` (2026-10-16). SHA-256: `d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b` (4,063,303 bytes; 635 paths).
`openapi.json` is an unchanged, owner-supplied OpenAPI 3.0.3 snapshot from `SpaceWorks-HQ/SpaceWorks`, `frontend/openapi-schema.json`, branch `feature/mobile-backend` at commit `f65ba332` (schema last regenerated in `3123173d`, 2026-10-17). SHA-256: `962e6a4b5b6927db0214c11e4b3b5ff688f660dccb8e674a7403b996cb1d8460` (4,997,776 bytes; 760 paths; 1,436 schemas). It supersedes the 2026-10-16 snapshot `d5ec904e…` (635 paths) and publishes the backend 1a–1d mobile contracts: native config/attestation, phone device delivery, `session_authority`, vendor-discriminated payment intents, account deletion/reauthentication, logout-all and the push/link association routes. The backend's companion prose for rules the schema cannot express is its `docs/mobile/MOBILE-APP-PRD.md` (v2) and `docs/mobile-integration.md`; `docs/PRD.md` here remains the byte-identical v1 owner copy.

`./gradlew :app:openApiGenerate` runs OpenAPI Generator 7.26.0 (`kotlin`, `jvm-retrofit2`, `kotlinx_serialization`, coroutines). `scripts/contract.py` mechanically selects implemented operation paths and their unchanged transitive schema references, avoiding unused API surface. The full authoritative snapshot remains committed. Expand the operation allowlist as each validated phase needs it. All client code lives in ignored build outputs; no handwritten request/response models.

Expand Down
132,006 changes: 77,770 additions & 54,236 deletions api/openapi.json

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,35 @@ android {
manifestPlaceholders["deepLinkVerify"] = ((brand["server_url"] as? String)?.isNotBlank()==true).toString()
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
// Store uploads inject a fresh versionCode/versionName without editing source.
versionCode = (findProperty("spaceworks.versionCode") as String?)?.toInt() ?: 1
versionName = (findProperty("spaceworks.versionName") as String?) ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", brand["app_name"] as String)
buildConfigField("String", "SERVER_URL", "\"${brand["server_url"] ?: ""}\"")
// Public Play Integrity Cloud project number (owner build input); 0 keeps production attestation unavailable.
val integrityProject = (findProperty("spaceworks.playIntegrityProject") as String?)?.toLongOrNull()
?: (brand["play_integrity_cloud_project_number"] as? Number)?.toLong() ?: 0L
require(integrityProject >= 0) { "play_integrity_cloud_project_number must be a positive project number or null" }
buildConfigField("long", "PLAY_INTEGRITY_PROJECT", "${integrityProject}L")
}
// The owner's upload key is injected only in the private release environment; never committed.
val uploadKeystore = providers.environmentVariable("SPACEWORKS_UPLOAD_KEYSTORE").orNull
signingConfigs {
if (uploadKeystore != null) create("upload") {
storeFile = file(uploadKeystore)
storePassword = providers.environmentVariable("SPACEWORKS_UPLOAD_KEYSTORE_PASSWORD").get()
keyAlias = providers.environmentVariable("SPACEWORKS_UPLOAD_KEY_ALIAS").get()
keyPassword = providers.environmentVariable("SPACEWORKS_UPLOAD_KEY_PASSWORD").get()
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.findByName("upload")
}
}
buildFeatures { compose = true; buildConfig = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
Expand Down Expand Up @@ -113,7 +137,12 @@ openApiGenerate {
// generator7.26 inline-union scalar serializer defects without editing schema.
schemaMappings.set(mapOf(
"TenantBootstrapMakerspace_map_url" to "String",
"SocialLogin_redirect_uri" to "String",
"ReauthenticationSocialStart_redirect_uri" to "String",
"WebsiteSection_url" to "String",
"WebsiteSection_website_url" to "String",
"TenantBootstrapMakerspace_opening_hours" to "kotlinx.serialization.json.JsonElement",
"NullEnum" to "kotlinx.serialization.json.JsonElement",
"TenantBootstrapMakerspace_opening_hours_oneOf_weekly" to "kotlinx.serialization.json.JsonElement"
))
enumNameMappings.set(mapOf("name" to "nameAscending", "" to "none"))
Expand Down
35 changes: 35 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Release shrinking rules. Library consumer rules (Retrofit, OkHttp, kotlinx.serialization, Stripe,
# CameraX, ML Kit, Play Integrity, Firebase) are applied automatically; only gaps are listed here.

# Generated Retrofit interfaces are resolved reflectively through their annotations and generic
# suspend signatures (Response<T>); keep the metadata R8 would otherwise strip.
-keepattributes Signature, InnerClasses, EnclosingMethod, RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations, AnnotationDefault
-keep,allowobfuscation interface org.spaceworks.generated.api.**

# kotlinx.serialization: keep generated serializers and companions of the generated client models.
-keepclassmembers @kotlinx.serialization.Serializable class org.spaceworks.generated.model.** {
*** Companion;
*** INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-keep class org.spaceworks.generated.model.**$$serializer { *; }
-keep class org.spaceworks.generated.model.**Serializer { *; }

# Credential Manager loads its Play Services provider reflectively.
-if class androidx.credentials.CredentialManager
-keep class androidx.credentials.playservices.** { *; }

# Razorpay Checkout (vendor-documented rules).
-keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; }
-keepattributes JavascriptInterface
-keepattributes *Annotation*
-dontwarn com.razorpay.**
-keep class com.razorpay.** { *; }
-optimizations !method/inlining/*
-keepclasseswithmembers class * { public void onPayment*(...); }

# Optional Google Play services/annotation references that are not packaged.
-dontwarn com.google.errorprone.annotations.**
-dontwarn javax.annotation.**
-dontwarn org.slf4j.impl.**
-dontwarn proguard.annotation.**
89 changes: 89 additions & 0 deletions app/src/androidTest/java/org/spaceworks/app/InsightsJourneyTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.spaceworks.app

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.spaceworks.app.auth.*
import org.spaceworks.app.insights.*
import org.spaceworks.app.member.MemberScope
import org.spaceworks.app.network.*
import org.spaceworks.app.theme.SpaceWorksTheme
import org.spaceworks.app.ui.AccountFlowScreen
import org.spaceworks.generated.model.*

private class JourneyInsights:InsightsGateway {
var sms=ConsentChannel(true,false,true,"•••• 3210",false)
var whatsapp=ConsentChannel(true,false,false,"",false)
var writes=mutableListOf<Pair<String,Boolean>>()
private fun series(vararg points:Pair<String,Int>)=UsageSeries(points.map {UsagePoint(it.first,it.second.toString(),it.second.toDouble())},"","")
override suspend fun usage(scope:MemberScope)=UsageSummary(true,LoanUsage(12,10,2,series("Sep 2026" to 4,"Oct 2026" to 3)),null,
CountUsage(5,series("Oct 2026" to 2)),3,PaymentUsage(mapOf("inr" to "1250.00"),emptyMap()))
override suspend fun dashboard(scope:MemberScope)=StaffDashboard(StaffScope.HANDOVER,emptyList(),
listOf(StaffCounter("ready_to_issue",4),StaffCounter("due_returns",1)),listOf(StaffWidget("hidden","Analytics","count","9")),emptyMap())
override suspend fun messaging(scope:MemberScope)=MessagingConsent(sms,whatsapp)
override suspend fun consent(scope:MemberScope,channel:String,optIn:Boolean):MessagingConsent {
writes+=channel to optIn;sms=sms.copy(consented=optIn);return MessagingConsent(sms,whatsapp)
}
}

class InsightsJourneyTest {
@get:Rule val compose=createComposeRule()
private val journey by lazy {NativeJourney(compose,"v2")}
private fun show(content:@androidx.compose.runtime.Composable ()->Unit) {
DeviceWindowGuard.awaitClear()
compose.setContent {SpaceWorksTheme(DeviceProfile.dark) {Surface(Modifier.fillMaxSize()) {Box(Modifier.fillMaxSize().safeDrawingPadding()) {content()}}}}
}
private fun holder(api:InsightsGateway)=InsightsStateHolder(api).apply {activate(MemberScope(7,"fab-lab"))}
@Test fun memberUsageShowsServerNumbersWithWrittenValues() {
val insights=holder(JourneyInsights())
show {Column(Modifier.verticalScroll(rememberScrollState()).padding(24.dp)) {MemberUsagePanel(insights)}}
journey.waitText("Your activity");journey.waitText("Out now")
journey.visible(compose.onNodeWithContentDescription("Sep 2026: 4",substring=true))
journey.waitText("INR 1250.00");journey.shot("v2-member-usage")
}
@Test fun handoverStaffSeeQueueTilesWithoutAnalytics() {
val insights=holder(JourneyInsights())
show {Column(Modifier.verticalScroll(rememberScrollState()).padding(24.dp)) {StaffDashboardPanel(insights)}}
journey.waitText("Handover queue");journey.waitText("Ready to issue");journey.waitText("Due back")
compose.onNodeWithText("Analytics").assertDoesNotExist();journey.shot("v2-staff-handover")
}
@Test fun consentSwitchFollowsServerAndExplainsVerification() {
val api=JourneyInsights();val insights=holder(api)
show {MessagingConsentScreen(insights,{},{})}
journey.waitText("Text and WhatsApp messages");journey.waitText("Messages go to •••• 3210.")
journey.waitText("Verify your phone number in Account and security before turning this on.")
compose.onNodeWithTag("consent-whatsapp").assertIsNotEnabled()
journey.clickTag("consent-sms")
compose.waitUntil(10000) {api.writes.isNotEmpty()}
assertEquals(listOf("sms" to true),api.writes);journey.waitText("On");journey.shot("v2-consent")
}
@Test fun phoneRestoreSendsCodeAndConfirmsRestoration() {
val auth=object:AuthGateway {
override suspend fun config()=PublicConfig(false,100)
override suspend fun login(username:String,password:String)=throw AssertionError()
override suspend fun enroll(challenge:String)=throw AssertionError()
override suspend fun verify(challenge:String,code:String,enrollment:Boolean)=throw AssertionError()
override suspend fun me()=throw AssertionError()
override suspend fun logout() {}
override suspend fun phoneStart(phone:String)=PhoneChallenge("c",300)
override suspend fun restorePhone(phone:String,code:String,newPassword:String,challenge:PhoneChallenge)=AccountRestoreResult(true,"Account restored.")
}
val accounts=AccountStateHolder(auth,{false}).apply {open(AccountRoute.RESTORE_PHONE)}
show {AccountFlowScreen(accounts,false)}
journey.waitText("Restore a deleted account");journey.shot("v2-restore-phone")
journey.replace(compose.onNodeWithTag("restorePhone"),"+919876543210");journey.clickTag("restoreSend")
journey.waitText("Enter the code we sent and choose a new password.")
journey.replace(compose.onNodeWithTag("restoreCode"),"123456")
journey.replace(compose.onNodeWithTag("restorePassword"),"new-password-1")
journey.replace(compose.onNodeWithTag("restoreConfirmation"),"new-password-1")
journey.clickTag("restoreSubmit");journey.waitText("Account restored");journey.shot("v2-restore-done")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package org.spaceworks.app

import androidx.compose.foundation.layout.*
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.spaceworks.app.account.*
import org.spaceworks.app.auth.*
import org.spaceworks.app.network.*
import org.spaceworks.app.theme.SpaceWorksTheme
import org.spaceworks.app.ui.AuthScreen
import org.spaceworks.generated.model.*

/** Production auth/deletion UI against in-test gateways shaped like the generated native contract. */
private class JourneyAuth(var minimum:String?=null):AuthGateway {
var codes=mutableListOf<String>()
override val nativeSocialReady=true
override suspend fun config()=PublicConfig(false,100,native=NativeConfig(NativeMinVersion(minimum,null),NativeProviders(true,true,null,null,emptyList())))
override suspend fun login(username:String,password:String):DeviceLoginResult=throw ApiFailure(401,"Wrong password.",null,null)
override suspend fun enroll(challenge:String)=throw AssertionError()
override suspend fun verify(challenge:String,code:String,enrollment:Boolean)=throw AssertionError()
override suspend fun me()=AuthUserPayload("member",1,"phone","","Phone Member","+919876543210",true,"member",false,false,emptyList())
override suspend fun logout() {}
override suspend fun phoneStart(phone:String)=PhoneChallenge("journey-challenge",300)
override suspend fun phoneConfirm(phone:String,code:String,challenge:PhoneChallenge):PhoneLoginResult {
codes+=code
if(code!="123456")throw ApiFailure(400,"Invalid or expired code.",null,null)
val at=java.time.OffsetDateTime.parse("2026-10-17T00:00:00Z")
return PhoneLoginResult.DeviceTokenResponseValue(DeviceTokenResponse("a","r",emptyMap(),
DeviceGrant(java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"),"android","org.spaceworks.app","development","active",at,at,at)))
}
}
private class JourneyDeletion:AccountDeletionGateway {
var blocked=true;var deleted=0
override suspend fun preview()=if(blocked)DeletionPreview(false,true,"You can restore it within 90 days.",
listOf(DeletionBlocker("unpaid_charges",7,"fab-lab",null,2,"Two charges need payment."),DeletionBlocker("future_code",null,null,null,1,"A server-only review is open.")),true,listOf("password"))
else DeletionPreview(true,true,"You can restore it within 90 days.",emptyList(),true,listOf("password"))
override suspend fun password(password:String,code:String?)=if(password=="correct")DeletionProof.Verified(600) else throw ApiFailure(0,"x","reauth_failed",null)
override suspend fun startPhone()=throw AssertionError()
override suspend fun confirmPhone(token:PhoneProofToken,code:String)=throw AssertionError()
override suspend fun startSocial(provider:String)=throw AssertionError()
override suspend fun completeSocial(intent:SocialProofIntent,idToken:String)=throw AssertionError()
override suspend fun factor(token:String,code:String)=throw AssertionError()
override suspend fun delete():DeletionOutcome {deleted++;return DeletionOutcome.Deleted("2026-10-18T00:00:00Z","2027-01-16T00:00:00Z")}
}

class NativeContractJourneyTest {
@get:Rule val compose=createComposeRule()
private val journey by lazy {NativeJourney(compose,"c-native")}
private fun show(content:@androidx.compose.runtime.Composable ()->Unit) {
DeviceWindowGuard.awaitClear()
compose.setContent {SpaceWorksTheme(DeviceProfile.dark) {Surface(Modifier.fillMaxSize()) {Box(Modifier.fillMaxSize().safeDrawingPadding()) {content()}}}}
}
@Test fun phoneSignInSendsCodeRejectsWrongCodeAndSignsIn() {
val api=JourneyAuth();val store=MemoryTokenStore();val holder=AuthStateHolder(api,store)
show {AuthScreen(AuthBundle(holder,store))}
journey.waitText("Continue with phone");journey.shot("c3-phone-entry")
journey.clickTag("phoneSignIn");journey.waitText("We'll text a six-digit code to this number.")
journey.replace(compose.onNodeWithTag("phoneNumber"),"9876543210");journey.clickTag("sendPhoneCode")
journey.waitText("Enter your verification code");journey.shot("c3-phone-code")
journey.replace(compose.onNodeWithTag("phoneCode"),"000000");journey.clickTag("verifyPhoneCode")
journey.waitText("Invalid or expired code.");journey.shot("c3-phone-invalid")
journey.replace(compose.onNodeWithTag("phoneCode"),"123456");journey.clickTag("verifyPhoneCode")
compose.waitUntil(10000) {holder.state.value.user!=null}
assertEquals(listOf("000000","123456"),api.codes);assertEquals(TokenPair("a","r"),store.read())
}
@Test fun belowMinimumVersionShowsUpdateRequired() {
val api=JourneyAuth(minimum="999.0");val store=MemoryTokenStore();val holder=AuthStateHolder(api,store,appVersion=BuildConfig.VERSION_NAME)
show {AuthScreen(AuthBundle(holder,store))}
journey.waitText("Update required")
journey.visible(compose.onNodeWithText("Open Google Play"));journey.shot("c3-update-required")
compose.onNodeWithText("Email or username").assertDoesNotExist()
}
@Test fun deletionShowsBlockersThenReauthenticatesAndRequiresTypedConfirmation() {
val api=JourneyDeletion();val holder=AccountDeletionStateHolder(api)
show {AccountDeletionScreen(holder,{},{_,_->})}
journey.waitText("Resolve these before deleting")
journey.visible(compose.onNodeWithTag("deletion-blocker-unpaid_charges"));journey.waitText("A server-only review is open.")
journey.shot("c4-deletion-blockers")
api.blocked=false;journey.clickTag("deletion-recheck")
journey.waitText("Verify it is you")
journey.replace(compose.onNodeWithTag("deletion-password"),"wrong");journey.clickTag("deletion-authenticate")
journey.waitText("Verification failed. Check your password or code.");journey.shot("c4-deletion-proof-failed")
journey.replace(compose.onNodeWithTag("deletion-password"),"correct");journey.clickTag("deletion-authenticate")
journey.waitText("Confirm account deletion")
journey.replace(compose.onNodeWithTag("deletion-confirmation"),"delete")
compose.onNodeWithTag("deletion-submit").assertIsNotEnabled()
journey.replace(compose.onNodeWithTag("deletion-confirmation"),"DELETE");journey.shot("c4-deletion-confirm")
journey.clickTag("deletion-submit")
journey.waitText("Your account was deleted and this device is signed out.");assertEquals(1,api.deleted)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class PaymentsJourneyTest {
@Composable fun paymentContent() {
org.spaceworks.app.theme.SpaceWorksTheme(dark){
Surface(Modifier.fillMaxSize()){Box(Modifier.fillMaxSize().safeDrawingPadding()){
PaymentsScreen(holder,true,{},launchStripe={launches.incrementAndGet();PaymentLaunchResult.DEMO_BLOCKED})
PaymentsScreen(holder,true,{},launchCheckout={launches.incrementAndGet();PaymentLaunchResult.DEMO_BLOCKED})
}}
}
}
Expand Down
6 changes: 5 additions & 1 deletion app/src/debug/assets/staff/booking.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
"public_token": "71000000-0000-4000-8000-000000000001",
"space_id": 901,
"starts_at": "2026-10-16T09:00:00Z",
"status": "pending"
"status": "pending",
"arrived_at": null,
"cancelled_at": null,
"restore_until": null,
"status_before_cancel": ""
}
Loading
Loading