Skip to main content

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:

PermissionDefaultControls
positiontrueView device location on map, access position history
eventstrueView device events (ignition, movement, alerts)
geofencestrueView and manage geofence zones for the device
notificationstrueConfigure and receive notification rules
commandsfalseSend 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 β€” maps deviceId β†’ 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​

  1. Owner opens SharingScreen for a device
  2. Taps the FAB (+) or empty-state CTA to open the invite bottom sheet
  3. Enters recipient email, toggles permissions
  4. SharingViewModel dispatches SendInvite β†’ ShareDeviceUseCase validates and calls API
  5. 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​

  1. WebSocket delivers share_invite_received β†’ InviteDataStore.addInvite() caches it
  2. InvitesListScreen observes InviteDataStore.invites flow and displays the invite with permission chips
  3. Recipient taps Accept β†’ AcceptInviteUseCase(token) β†’ API confirms β†’ device appears in recipient's device list
  4. 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​

ElementOwnerShared User
FAB (+)Shown (when shares exist)Hidden
Empty state CTA"Share Device" buttonNot shown
Share/invite listsVisibleHidden
Leave device sectionHiddenShown 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​

  1. Owner taps the close (βœ•) icon on a ShareCard in SharingScreen
  2. SharingIntent.ShowRevokeConfirmation(share) triggers a confirmation dialog
  3. On confirm β†’ SharingIntent.ConfirmRevoke β†’ RevokeShareUseCase(deviceId, userId)
  4. On success, SharingDataStore.removeShare() updates the local cache immediately
  5. 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​

  1. Shared user taps "Leave" in the LeaveDeviceSection
  2. SharingIntent.LeaveDevice β†’ LeaveDeviceUseCase(deviceId) β†’ API call
  3. SharingEvent.Left fires β†’ 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 DeviceShare carries its own SharePermissions β€” User A might have commands access while User B does not
  • The SharingScreen lists all active shares and pending invites for the device
  • The owner can revoke individual users independently
  • When a user accepts an invite, the SharingDataStore updates the existing DeviceSharesInfo by 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 EventHandlerEffect
share_invite_receivedInviteDataStore.addInvite()New invite appears in recipient's invite list
share_acceptedSharingDataStore.onShareAccepted()Pending invite moves to active share for owner
share_revokedDeviceDataStore.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.