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)
}
| Method | HTTP | Path | Body | Returns |
|---|---|---|---|---|
shareDevice | POST | api/sharing/devices/{deviceId}/share | ShareDeviceRequest | ShareDeviceResponseDto |
getDeviceShares | GET | api/sharing/devices/{deviceId}/shares | — | DeviceSharesDto |
revokeShare | DELETE | api/sharing/devices/{deviceId}/share/{userId} | — | Unit |
leaveDevice | DELETE | api/sharing/devices/{deviceId}/leave | — | Unit |
getMyInvites | GET | api/sharing/invites | — | List<UserInviteDto> |
acceptInvite | POST | api/sharing/accept | AcceptInviteRequest | AcceptInviteResponseDto |
cancelInvite | DELETE | api/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
| Method | Input | Output |
|---|---|---|
toDomain(SharePermissionsDto) | SharePermissionsDto | SharePermissions |
toDomain(SharedUserDto) | SharedUserDto | SharedUser |
toDomain(DeviceShareDto) | DeviceShareDto | DeviceShare |
toDomain(PendingInviteDto) | PendingInviteDto | PendingInvite |
toDomain(DeviceSharesDto) | DeviceSharesDto | DeviceSharesInfo |
toDomain(ShareDeviceResponseDto) | ShareDeviceResponseDto | ShareDeviceResult |
toDomain(UserInviteDto) | UserInviteDto | UserInvite |
toDomain(AcceptInviteResponseDto) | AcceptInviteResponseDto | AcceptInviteResult |
Domain → DTO
| Method | Input | Output |
|---|---|---|
toDto(SharePermissions) | SharePermissions | SharePermissionsDto |
Notable mapping logic
toDomain(SharedUserDto)— mapsdto.userId→SharedUser.id; falls back to""for null email.toDomain(DeviceShareDto)— inline-constructs aSharedUserfrom flat fields; defaults permissions toSharePermissions()andsharedAttoInstant.now()when null.toDomain(UserInviteDto)— extractsinvitedBydisplay string: prefersname(if non-blank), falls back toemail.toDomain(ShareDeviceResponseDto)— mapstargetUser?.userIdandtargetUser?.emailto flat fields onShareDeviceResult.
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:
- Build a request DTO (if needed) and call
sharingApi. - Map the response through
sharingMapper.toDomain(...). - Catch
HttpException→ wrap inNetworkException("HTTP error: ${e.code()}"). - Catch
IOException→ wrap inNetworkException(contextMessage, e).
| Method | API call | Request DTO | Mapping |
|---|---|---|---|
shareDevice | sharingApi.shareDevice(deviceId, request) | ShareDeviceRequest(email, permissions?.let { sharingMapper.toDto(it) }) | sharingMapper.toDomain(response) → ShareDeviceResult |
getDeviceShares | sharingApi.getDeviceShares(deviceId) | — | sharingMapper.toDomain(response) → DeviceSharesInfo |
revokeShare | sharingApi.revokeShare(deviceId, userId) | — | — |
leaveDevice | sharingApi.leaveDevice(deviceId) | — | — |
getMyInvites | sharingApi.getMyInvites() | — | response.map { sharingMapper.toDomain(it) } → List<UserInvite> |
acceptInvite | sharingApi.acceptInvite(request) | AcceptInviteRequest(token) | sharingMapper.toDomain(response) → AcceptInviteResult |
cancelInvite | sharingApi.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
| Property | Type | Description |
|---|---|---|
deviceShares | StateFlow<Map<Int, DeviceSharesInfo>> | Cached shares keyed by device ID |
shareAcceptedEvent | SharedFlow<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
| Method | Description |
|---|---|
setDeviceShares | Stores/replaces the full DeviceSharesInfo for a device |
getDeviceShares | Returns cached info or null |
onShareAccepted | Updates cache: adds/replaces the share, removes the matching pending invite (case-insensitive email), emits shareAcceptedEvent |
addPendingInvite | Appends a PendingInvite to existing device cache (no-op if device not loaded) |
removeShare | Filters out the share with matching userId |
clearDevice | Removes all cached data for a single device |
clear | Resets entire cache to empty |
isDeviceLoaded | Returns 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
| Property | Type | Description |
|---|---|---|
invites | StateFlow<List<UserInvite>> | Current invite list, sorted by expiresAt |
isInitialized | StateFlow<Boolean> | true after the first setInvites call |
newInviteEvent | SharedFlow<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
| Method | Description |
|---|---|
setInvites | Replaces the full invite list (sorted by expiresAt), sets isInitialized = true |
addInvite | Converts ShareInviteReceivedEvent → UserInvite, deduplicates by token, appends & re-sorts, emits newInviteEvent |
removeInvite | Filters out the invite matching token |
clear | Resets invites to empty and isInitialized to false |
invalidate | Sets isInitialized = false without clearing data (triggers re-fetch on next access) |
getInviteCount | Returns current number of cached invites |