Skip to main content

Sharing Service — Data Layer

Covers the full sharing data pipeline: Retrofit API → DTOs → Mapper → Repository → DataStores → UI.


SharingApi

Retrofit interface at data/remote/api/SharingApi.kt. All methods are suspend.

interface SharingApi {

@POST("api/sharing/devices/{deviceId}/share")
suspend fun shareDevice(@Path("deviceId") deviceId: Int, @Body request: ShareDeviceRequest): ShareDeviceResponseDto

@GET("api/sharing/devices/{deviceId}/shares")
suspend fun getDeviceShares(@Path("deviceId") deviceId: Int): DeviceSharesDto

@DELETE("api/sharing/devices/{deviceId}/share/{userId}")
suspend fun revokeShare(@Path("deviceId") deviceId: Int, @Path("userId") userId: Int)

@DELETE("api/sharing/devices/{deviceId}/leave")
suspend fun leaveDevice(@Path("deviceId") deviceId: Int)

@GET("api/sharing/invites")
suspend fun getMyInvites(): List<UserInviteDto>

@POST("api/sharing/accept")
suspend fun acceptInvite(@Body request: AcceptInviteRequest): AcceptInviteResponseDto

@DELETE("api/sharing/invites/{token}")
suspend fun cancelInvite(@Path("token") token: String)
}
MethodHTTPPathBodyReturns
shareDevicePOSTapi/sharing/devices/{deviceId}/shareShareDeviceRequestShareDeviceResponseDto
getDeviceSharesGETapi/sharing/devices/{deviceId}/sharesDeviceSharesDto
revokeShareDELETEapi/sharing/devices/{deviceId}/share/{userId}Unit
leaveDeviceDELETEapi/sharing/devices/{deviceId}/leaveUnit
getMyInvitesGETapi/sharing/invitesList<UserInviteDto>
acceptInvitePOSTapi/sharing/acceptAcceptInviteRequestAcceptInviteResponseDto
cancelInviteDELETEapi/sharing/invites/{token}Unit

Sharing DTOs

All DTOs live in data/remote/dto/SharingDtos.kt.

Request Models

data class ShareDeviceRequest(val email: String, val permissions: SharePermissionsDto? = null)

data class AcceptInviteRequest(val token: String)

Response Models

data class ShareDeviceResponseDto(
val success: Boolean,
val shareToken: String,
val targetUser: TargetUserDto? = null,
val expiresAt: String
)

data class TargetUserDto(val userId: Int? = null, val email: String)

data class AcceptInviteResponseDto(
val success: Boolean,
val deviceId: Int,
val permissions: SharePermissionsDto? = null
)

Shared/Reference Models

data class SharePermissionsDto(
val position: Boolean = true,
val events: Boolean = true,
val geofences: Boolean = true,
val notifications: Boolean = true,
val commands: Boolean = false
)

data class SharedUserDto(val userId: Int, val email: String? = null, val name: String? = null)

data class DeviceShareDto(
val userId: Int,
val email: String? = null,
val name: String? = null,
val permissions: SharePermissionsDto? = null,
val sharedAt: String? = null
)

data class PendingInviteDto(
val token: String,
val email: String,
val permissions: SharePermissionsDto,
val createdAt: String,
val expiresAt: String
)

data class DeviceSharesDto(
val deviceId: Int,
val owner: SharedUserDto,
val sharedWith: List<DeviceShareDto>,
val pendingInvites: List<PendingInviteDto>
)

data class UserInviteDto(
val token: String,
val deviceId: Int,
val deviceModel: String? = null,
val invitedBy: InvitedByDto? = null,
val permissions: SharePermissionsDto? = null,
val expiresAt: String
)

data class InvitedByDto(val userId: Int? = null, val email: String? = null, val name: String? = null)

SharingMapper

data/mappers/SharingMapper.kt@Singleton, injected via @Inject constructor().

Converts between DTOs and domain entities. Timestamps are parsed with Instant.parse(), falling back to Instant.now() on failure.

DTO → Domain

MethodInputOutput
toDomain(SharePermissionsDto)SharePermissionsDtoSharePermissions
toDomain(SharedUserDto)SharedUserDtoSharedUser
toDomain(DeviceShareDto)DeviceShareDtoDeviceShare
toDomain(PendingInviteDto)PendingInviteDtoPendingInvite
toDomain(DeviceSharesDto)DeviceSharesDtoDeviceSharesInfo
toDomain(ShareDeviceResponseDto)ShareDeviceResponseDtoShareDeviceResult
toDomain(UserInviteDto)UserInviteDtoUserInvite
toDomain(AcceptInviteResponseDto)AcceptInviteResponseDtoAcceptInviteResult

Domain → DTO

MethodInputOutput
toDto(SharePermissions)SharePermissionsSharePermissionsDto

Notable mapping logic

  • toDomain(SharedUserDto) — maps dto.userIdSharedUser.id; falls back to "" for null email.
  • toDomain(DeviceShareDto) — inline-constructs a SharedUser from flat fields; defaults permissions to SharePermissions() and sharedAt to Instant.now() when null.
  • toDomain(UserInviteDto) — extracts invitedBy display string: prefers name (if non-blank), falls back to email.
  • toDomain(ShareDeviceResponseDto) — maps targetUser?.userId and targetUser?.email to flat fields on ShareDeviceResult.

SharingRepository

Interface at domain/repositories/SharingRepository.kt.

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)
}

SharingRepositoryImpl

data/repositories/SharingRepositoryImpl.kt@Singleton, Hilt-injected.

Constructor

@Singleton
class SharingRepositoryImpl @Inject constructor(
private val sharingApi: SharingApi,
private val sharingMapper: SharingMapper
) : SharingRepository

Implementation details

Every method follows the same error-handling pattern:

  1. Build a request DTO (if needed) and call sharingApi.
  2. Map the response through sharingMapper.toDomain(...).
  3. Catch HttpException → wrap in NetworkException("HTTP error: ${e.code()}").
  4. Catch IOException → wrap in NetworkException(contextMessage, e).
MethodAPI callRequest DTOMapping
shareDevicesharingApi.shareDevice(deviceId, request)ShareDeviceRequest(email, permissions?.let { sharingMapper.toDto(it) })sharingMapper.toDomain(response)ShareDeviceResult
getDeviceSharessharingApi.getDeviceShares(deviceId)sharingMapper.toDomain(response)DeviceSharesInfo
revokeSharesharingApi.revokeShare(deviceId, userId)
leaveDevicesharingApi.leaveDevice(deviceId)
getMyInvitessharingApi.getMyInvites()response.map { sharingMapper.toDomain(it) }List<UserInvite>
acceptInvitesharingApi.acceptInvite(request)AcceptInviteRequest(token)sharingMapper.toDomain(response)AcceptInviteResult
cancelInvitesharingApi.cancelInvite(token)

SharingDataStore

core/data/SharingDataStore.kt@Singleton, injected via @Inject constructor().

In-memory cache of per-device sharing info with real-time update support. All mutating methods are Mutex-guarded.

Exposed flows

PropertyTypeDescription
deviceSharesStateFlow<Map<Int, DeviceSharesInfo>>Cached shares keyed by device ID
shareAcceptedEventSharedFlow<ShareAcceptedEvent>Emitted when an invite is accepted (buffer capacity 16)

Methods

suspend fun setDeviceShares(deviceId: Int, info: DeviceSharesInfo)
fun getDeviceShares(deviceId: Int): DeviceSharesInfo?
suspend fun onShareAccepted(event: ShareAcceptedEvent)
suspend fun addPendingInvite(deviceId: Int, invite: PendingInvite)
suspend fun removeShare(deviceId: Int, userId: Int)
suspend fun clearDevice(deviceId: Int)
suspend fun clear()
fun isDeviceLoaded(deviceId: Int): Boolean
MethodDescription
setDeviceSharesStores/replaces the full DeviceSharesInfo for a device
getDeviceSharesReturns cached info or null
onShareAcceptedUpdates cache: adds/replaces the share, removes the matching pending invite (case-insensitive email), emits shareAcceptedEvent
addPendingInviteAppends a PendingInvite to existing device cache (no-op if device not loaded)
removeShareFilters out the share with matching userId
clearDeviceRemoves all cached data for a single device
clearResets entire cache to empty
isDeviceLoadedReturns whether the device has been cached

InviteDataStore

core/data/InviteDataStore.kt@Singleton, injected via @Inject constructor().

Single source of truth for the current user's pending invites. Receives real-time updates via WebSocket. All mutating methods are Mutex-guarded.

Exposed flows

PropertyTypeDescription
invitesStateFlow<List<UserInvite>>Current invite list, sorted by expiresAt
isInitializedStateFlow<Boolean>true after the first setInvites call
newInviteEventSharedFlow<UserInvite>Emitted when a new invite arrives (buffer capacity 16)

Methods

suspend fun setInvites(invites: List<UserInvite>)
suspend fun addInvite(event: ShareInviteReceivedEvent)
suspend fun removeInvite(token: String)
suspend fun clear()
fun invalidate()
fun getInviteCount(): Int
MethodDescription
setInvitesReplaces the full invite list (sorted by expiresAt), sets isInitialized = true
addInviteConverts ShareInviteReceivedEventUserInvite, deduplicates by token, appends & re-sorts, emits newInviteEvent
removeInviteFilters out the invite matching token
clearResets invites to empty and isInitialized to false
invalidateSets isInitialized = false without clearing data (triggers re-fetch on next access)
getInviteCountReturns current number of cached invites