Sharing & Permissions
This document covers the Visla GPS Android app's device sharing model β the granular permission system, invite lifecycle, UI enforcement, real-time updates, and multi-user behavior. It is the top-level reference for how sharing works from domain entities through UI rendering.
For API endpoints, DTOs, and network-layer details, see data-layer/sharing.md. For end-to-end user flow diagrams, see user-flows.md.
Architecture Overviewβ
Data flow: Owner sends invite β API creates pending invite β recipient receives WebSocket share_invite_received event β InviteDataStore updates β UI shows invite β recipient accepts β WebSocket share_accepted event β SharingDataStore updates β owner's UI refreshes in real time.
Granular Permission Systemβ
Every share carries five independent boolean permissions that control exactly what the recipient can do with the device:
| Permission | Default | Controls |
|---|---|---|
position | true | View device location on map, access position history |
events | true | View device events (ignition, movement, alerts) |
geofences | true | View and manage geofence zones for the device |
notifications | true | Configure and receive notification rules |
commands | false | Send commands to the device (engine lock, buzzer, etc.) |
Note that commands defaults to false β sending commands to a GPS tracker is a privileged action and must be explicitly granted.
Permission Entitiesβ
Two parallel types represent permissions at different stages of the sharing lifecycle:
// Sharing.kt β attached to invites and shares
data class SharePermissions(
val position: Boolean = true,
val events: Boolean = true,
val geofences: Boolean = true,
val notifications: Boolean = true,
val commands: Boolean = false
)
// Device.kt β resolved on the Device entity for UI consumption
data class DevicePermissions(
val position: Boolean = true,
val events: Boolean = true,
val geofences: Boolean = true,
val notifications: Boolean = true,
val commands: Boolean = true
) {
companion object {
fun full() = DevicePermissions(/* all true */)
fun none() = DevicePermissions(/* all false */)
}
}
SharePermissions travels with invite/share data. DevicePermissions is resolved onto the Device entity so the UI can check access without knowing whether a device is shared or owned.
Permission Modelβ
How Permissions Are Definedβ
When an owner invites a user, they select permissions via toggle switches in the invite bottom sheet. The ViewModel holds these as individual boolean fields:
data class SharingUiState(
// ...
val permPosition: Boolean = true,
val permEvents: Boolean = true,
val permGeofences: Boolean = true,
val permNotifications: Boolean = true,
val permCommands: Boolean = false,
// ...
)
On send, these are assembled into a SharePermissions object:
val permissions = SharePermissions(
position = state.permPosition,
events = state.permEvents,
geofences = state.permGeofences,
notifications = state.permNotifications,
commands = state.permCommands
)
shareDeviceUseCase(deviceId, state.inviteEmail, permissions)
How Permissions Are Storedβ
- Server-side: The API stores permissions per share. See data-layer/sharing.md for DTO shapes.
- Client-side: Two DataStores cache sharing state:
SharingDataStoreβ mapsdeviceId β DeviceSharesInfo(shares + pending invites)InviteDataStoreβ holds the current user's pending inbound invites
How Permissions Are Enforcedβ
The Device entity exposes computed properties that merge ownership status with permissions:
data class Device(
// ...
val isOwner: Boolean,
val permissions: DevicePermissions = DevicePermissions.full(),
) {
val canViewPosition: Boolean get() = isOwner || permissions.position
val canViewEvents: Boolean get() = isOwner || permissions.events
val canManageGeofences: Boolean get() = isOwner || permissions.geofences
val canManageNotifications: Boolean get() = isOwner || permissions.notifications
val canSendCommands: Boolean get() = isOwner || permissions.commands
val canManageSharing: Boolean get() = isOwner
val canTransferOwnership: Boolean get() = isOwner
val canDelete: Boolean get() = isOwner
val canMute: Boolean get() = isOwner
}
Owners always have full access β permissions are only enforced for shared users. Management actions (sharing, transfer, delete, mute) are owner-exclusive and not grantable.
Invite Flowβ
Lifecycleβ
Owner Side: Creating an Inviteβ
- Owner opens
SharingScreenfor a device - Taps the FAB (+) or empty-state CTA to open the invite bottom sheet
- Enters recipient email, toggles permissions
SharingViewModeldispatchesSendInviteβShareDeviceUseCasevalidates and calls API- On success,
loadShares()refreshes the shares list and the invite appears as pending
Use Case Validationβ
class ShareDeviceUseCase @Inject constructor(
private val sharingRepository: SharingRepository
) {
suspend operator fun invoke(
deviceId: Int,
email: String,
permissions: SharePermissions? = null
): ShareDeviceResult {
if (deviceId <= 0) throw ValidationException("Invalid device ID", "deviceId")
val normalizedEmail = email.trim().lowercase()
if (normalizedEmail.isEmpty() || !normalizedEmail.contains("@"))
throw InvalidEmailException()
return sharingRepository.shareDevice(deviceId, normalizedEmail, permissions)
}
}
Recipient Side: Accepting or Decliningβ
- WebSocket delivers
share_invite_receivedβInviteDataStore.addInvite()caches it InvitesListScreenobservesInviteDataStore.invitesflow and displays the invite with permission chips- Recipient taps Accept β
AcceptInviteUseCase(token)β API confirms β device appears in recipient's device list - Recipient taps Decline β
CancelInviteUseCase(token)β invite removed
Invite Entitiesβ
// Outbound invite (owner's view)
data class PendingInvite(
val token: String,
val email: String,
val permissions: SharePermissions,
val createdAt: Instant,
val expiresAt: Instant
)
// Inbound invite (recipient's view)
data class UserInvite(
val token: String,
val deviceId: Int,
val deviceModel: String?,
val invitedBy: String?,
val permissions: SharePermissions?,
val expiresAt: Instant
)
Shared vs Owned Device Behaviorβ
When a device is shared (not owned), the UI adapts in several ways:
DeviceDetailScreen Adaptationsβ
Conditional action items:
// Only shown if the user has permission
if (device.canManageGeofences) { /* Geofences row */ }
if (device.canSendCommands) { /* Commands row */ }
if (device.canManageSharing) { /* Sharing row β owner only */ }
if (device.canManageNotifications) { /* Notifications row */ }
Owner info section β shared users see who owns the device:
if (!device.isOwner) {
uiState.sharesInfo?.owner?.let { owner ->
val ownerName = owner.name ?: owner.email
CopyableRow(
label = stringResource(R.string.device_owner),
value = "$ownerName (${owner.email})",
// ...
)
}
}
Remove vs Leave β different actions for owners and shared users:
// ViewModel
if (currentDevice.isOwner) {
deviceRepository.unclaimDevice(currentDeviceId) // owner unclaims
} else {
sharingRepository.leaveDevice(currentDeviceId) // shared user leaves
}
The confirmation dialog text also changes based on ownership β owners see "unclaim" language while shared users see "leave" language.
Conditional Data Loadingβ
The ViewModel skips loading data the user cannot access:
if (device.canSendCommands) {
loadCommandTypes(device)
}
if (device.canManageNotifications) {
loadNotificationSettings(deviceId)
}
SharingScreen Adaptationsβ
| Element | Owner | Shared User |
|---|---|---|
| FAB (+) | Shown (when shares exist) | Hidden |
| Empty state CTA | "Share Device" button | Not shown |
| Share/invite lists | Visible | Hidden |
| Leave device section | Hidden | Shown with error-colored action |
// FAB only for owners with existing shares
if (device.isOwner && hasShares) {
CircleIconButton(icon = Icons.Default.Add, /* ... */)
}
// Leave section only for non-owners
if (!device.isOwner) {
LeaveDeviceSection(onLeave = { viewModel.handle(SharingIntent.LeaveDevice) })
}
Revoking Accessβ
Owner Revokes a Userβ
- Owner taps the close (β) icon on a
ShareCardinSharingScreen SharingIntent.ShowRevokeConfirmation(share)triggers a confirmation dialog- On confirm β
SharingIntent.ConfirmRevokeβRevokeShareUseCase(deviceId, userId) - On success,
SharingDataStore.removeShare()updates the local cache immediately - Snackbar confirms: "Access revoked for
{userName}"
private fun confirmRevoke() {
val share = _uiState.value.revokeConfirmShare ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRevoking = true)
try {
revokeShareUseCase(deviceId, share.user.id)
sharingDataStore.removeShare(deviceId, share.user.id)
_event.value = SharingEvent.RevokeSuccess(userName)
} catch (e: Exception) {
_event.value = SharingEvent.Error(e.message ?: "Failed to revoke access")
}
}
}
Shared User Leavesβ
- Shared user taps "Leave" in the
LeaveDeviceSection SharingIntent.LeaveDeviceβLeaveDeviceUseCase(deviceId)β API callSharingEvent.Leftfires β screen navigates back and the device is removed from the user's list
Server-Side Revocation (Push)β
When the owner revokes via another client, the server sends a share_revoked WebSocket event:
// RealTimeDataBridge.setupShareEvents()
realTimeRepository.shareRevoked.collect { deviceId ->
deviceDataStore.removeDevice(deviceId)
geofenceDataStore.invalidateDevice(deviceId)
}
The device disappears from the shared user's device list immediately.
Multi-User Scenariosβ
A device can be shared with multiple users, each with different permissions:
data class DeviceSharesInfo(
val deviceId: Int,
val owner: SharedUser,
val shares: List<DeviceShare>, // active shares
val pendingInvites: List<PendingInvite> // awaiting response
)
- Each
DeviceSharecarries its ownSharePermissionsβ User A might have commands access while User B does not - The
SharingScreenlists all active shares and pending invites for the device - The owner can revoke individual users independently
- When a user accepts an invite, the
SharingDataStoreupdates the existingDeviceSharesInfoby moving the user from pending to active:
suspend fun onShareAccepted(event: ShareAcceptedEvent) = mutex.withLock {
val existing = _deviceShares.value[deviceId]
if (existing != null) {
val newShare = DeviceShare(
user = SharedUser(id = event.acceptedByUserId, email = event.acceptedByEmail, name = null),
permissions = event.permissions,
sharedAt = Instant.now()
)
val updatedPending = existing.pendingInvites.filter {
it.email.lowercase() != event.acceptedByEmail.lowercase()
}
// Upsert: update existing share or add new one
val updatedShares = if (existing.shares.any { it.user.id == event.acceptedByUserId }) {
existing.shares.map { if (it.user.id == event.acceptedByUserId) newShare else it }
} else {
existing.shares + newShare
}
// ...
}
}
Real-Time Sharing Updatesβ
Sharing state is kept fresh through WebSocket events, avoiding polling. Three event types are handled:
| WebSocket Event | Handler | Effect |
|---|---|---|
share_invite_received | InviteDataStore.addInvite() | New invite appears in recipient's invite list |
share_accepted | SharingDataStore.onShareAccepted() | Pending invite moves to active share for owner |
share_revoked | DeviceDataStore.removeDevice() | Device disappears from shared user's list |
Event Flowβ
Permission Mapping from WebSocketβ
private fun toDomain(dto: WsPermissionsDto?): SharePermissions = SharePermissions(
position = dto?.position ?: true,
events = dto?.events ?: true,
geofences = dto?.geofences ?: true,
notifications = dto?.notifications ?: true,
commands = dto?.commands ?: false
)
Missing permission fields default to the same values as SharePermissions defaults β permissive for read operations, restrictive for commands.
Real-Time UI Observationβ
The SharingViewModel directly observes SharingDataStore so WebSocket-driven updates appear immediately:
init {
viewModelScope.launch {
sharingDataStore.deviceShares.collect { sharesMap ->
val info = sharesMap[deviceId]
if (info != null) {
_uiState.value = _uiState.value.copy(
shares = info.shares,
pendingInvites = info.pendingInvites,
isLoading = false
)
}
}
}
viewModelScope.launch {
sharingDataStore.shareAcceptedEvent.collect { event ->
if (event.deviceId == deviceId) {
_uiState.value = _uiState.value.copy(
successMessage = "${event.acceptedByEmail} has accepted your invitation"
)
}
}
}
}
API Surfaceβ
The SharingRepository interface defines all sharing operations:
interface SharingRepository {
suspend fun shareDevice(deviceId: Int, email: String, permissions: SharePermissions? = null): ShareDeviceResult
suspend fun getDeviceShares(deviceId: Int): DeviceSharesInfo
suspend fun revokeShare(deviceId: Int, userId: Int)
suspend fun leaveDevice(deviceId: Int)
suspend fun getMyInvites(): List<UserInvite>
suspend fun acceptInvite(token: String): AcceptInviteResult
suspend fun cancelInvite(token: String)
}
Each operation has a corresponding use case that adds input validation. See data-layer/sharing.md for the Retrofit API definitions and DTO mappings.
Design Decisionsβ
Two Permission Types (SharePermissions vs DevicePermissions)β
SharePermissions represents what the owner grants β it defaults commands to false for safety. DevicePermissions represents resolved access on the device entity β it defaults everything to true because owners have full access. This separation keeps the invite UX safe-by-default while allowing the Device entity to work uniformly for both owned and shared devices.
Computed Properties on Deviceβ
Rather than scattering if (isOwner || hasPermission) checks across the UI, the Device entity exposes canViewPosition, canManageGeofences, etc. This centralizes permission logic in one place, making it impossible for a UI component to forget an ownership check.
Owner-Exclusive Management Actionsβ
Sharing management, device deletion, muting, and ownership transfer are hardcoded as owner-only (val canManageSharing: Boolean get() = isOwner). These are not part of the permission grant system because delegating management rights would create complex trust chains.
Optimistic Local Updatesβ
When revoking a share, the ViewModel immediately calls sharingDataStore.removeShare() after the API succeeds, rather than re-fetching from the server. This provides instant UI feedback. WebSocket events handle the reverse direction β when another client makes changes, the DataStore is updated via RealTimeDataBridge.
DataStore Deduplication on Acceptβ
When a share_accepted event arrives, SharingDataStore.onShareAccepted() performs an upsert β if the user already exists in the shares list (e.g., from a re-invite), it updates rather than duplicates. It also removes the matching pending invite by email comparison.
Invite Expirationβ
Invites carry an expiresAt timestamp. The InviteDataStore sorts invites by expiration so the most urgent appear first. The WebSocket mapper falls back to a one-week expiry if the server doesn't provide a timestamp.
Thread Safetyβ
Both SharingDataStore and InviteDataStore use Mutex to protect all mutations, ensuring thread-safe updates when WebSocket events and API responses arrive concurrently.