fix(devices): register devices as enabled and surface toggle feedback

The 2.8 Kotlin client stopped sending `enabled` on registration, so the
backend created devices with the schema default `enabled: false` ("Disabled"),
which users could not activate. Default new registrations to enabled on the
server (so existing 2.8 clients are fixed without an app update), still gated
by the device-limit check, and send `enabled = true` from onboarding.

Also make the gateway toggle always give feedback: show a success toast on
enable/disable and surface the server's reason (e.g. device-limit 429) on
failure instead of silently snapping back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-06-13 19:26:35 +03:00
parent 164124d616
commit de8df1c53c
6 changed files with 85 additions and 1 deletions

View File

@@ -0,0 +1,17 @@
package com.vernu.sms.helpers
import org.json.JSONObject
import retrofit2.Response
/**
* Extracts the human-readable `message` field the API returns in error bodies
* (e.g. the device-limit 429), falling back to null when it can't be parsed.
*/
fun Response<*>.serverErrorMessage(): String? {
val raw = errorBody()?.string()?.takeIf { it.isNotBlank() } ?: return null
return try {
JSONObject(raw).optString("message").takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}

View File

@@ -58,6 +58,13 @@ fun DashboardScreen(
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.userMessage) {
state.userMessage?.let { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
viewModel.consumeUserMessage()
}
}
fun checkMissingPermissions() = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}

View File

@@ -12,6 +12,7 @@ import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfile
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.helpers.serverErrorMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -29,7 +30,8 @@ data class DashboardState(
val subscriptionUnavailable: Boolean = false,
val userProfile: UserProfile? = null,
val availableSims: List<SimInfoDTO> = emptyList(),
val isReceiveSmsEnabled: Boolean = false
val isReceiveSmsEnabled: Boolean = false,
val userMessage: String? = null
)
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
@@ -135,6 +137,10 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun consumeUserMessage() {
_state.update { it.copy(userMessage = null) }
}
fun setReceiveSms(enabled: Boolean) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
@@ -175,9 +181,25 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
} catch (e: Exception) {
TextBeeUtils.logException(e, "Gateway service toggle failed")
}
_state.update {
it.copy(
userMessage = if (enabled) "Gateway enabled" else "Gateway disabled"
)
}
} else {
// Surface the server's reason (e.g. device-limit 429) so the
// toggle doesn't silently snap back with no explanation.
val message = response.serverErrorMessage()
?: if (response.code() == 429) {
"You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
} else {
"Couldn't update the gateway. Please try again."
}
_state.update { it.copy(userMessage = message) }
}
} catch (e: Exception) {
TextBeeUtils.logException(e, "Gateway toggle failed")
_state.update { it.copy(userMessage = "Couldn't update the gateway. Please check your connection.") }
} finally {
_state.update { it.copy(isTogglingGateway = false) }
}

View File

@@ -13,6 +13,7 @@ import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.SimInfoCollectionDTO
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.helpers.serverErrorMessage
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -98,6 +99,7 @@ class OnboardingViewModel : ViewModel() {
}
val input = RegisterDeviceInputDTO().apply {
this.fcmToken = fcmToken
enabled = true
brand = Build.BRAND
manufacturer = Build.MANUFACTURER
model = Build.MODEL
@@ -156,6 +158,8 @@ class OnboardingViewModel : ViewModel() {
errorMessage = when (response.code()) {
401 -> "Invalid API key. Go back and check your key."
404 -> "Device ID not found. Verify it in your dashboard."
429 -> response.serverErrorMessage()
?: "You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
in 500..599 -> "Server error. Please try again in a moment."
else -> "Request failed (${response.code()}). Please try again."
}

View File

@@ -76,6 +76,8 @@ describe('GatewayService', () => {
const mockBillingService = {
canPerformAction: jest.fn(),
getUserLimits: jest.fn(),
notifyDeviceLimitReached: jest.fn(),
}
const mockSmsQueueService = {
@@ -228,6 +230,37 @@ describe('GatewayService', () => {
})
expect(result).toBeDefined()
})
it('should default a new device to enabled when the client omits enabled', async () => {
// 2.8+ clients register without an `enabled` field; the server must
// still create the device enabled so it works without a manual toggle.
const inputWithoutEnabled: RegisterDeviceInputDTO = {
model: 'Pixel 6',
buildId: 'build123',
fcmToken: 'token123',
}
mockDeviceModel.findOne.mockResolvedValue(null)
mockBillingService.getUserLimits.mockResolvedValue({ deviceLimit: -1 })
mockDeviceModel.create.mockResolvedValue({ _id: 'device123' })
await service.registerDevice(inputWithoutEnabled, mockUser)
expect(mockDeviceModel.create).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
)
})
it('should block registration when the device limit is already reached', async () => {
mockDeviceModel.findOne.mockResolvedValue(null)
mockBillingService.getUserLimits.mockResolvedValue({ deviceLimit: 1 })
mockBillingService.notifyDeviceLimitReached.mockResolvedValue(undefined)
mockDeviceModel.countDocuments.mockResolvedValue(1)
await expect(
service.registerDevice(mockDeviceInput, mockUser),
).rejects.toMatchObject({ status: HttpStatus.TOO_MANY_REQUESTS })
expect(mockDeviceModel.create).not.toHaveBeenCalled()
})
})
describe('getDevicesForUser', () => {

View File

@@ -133,6 +133,7 @@ export class GatewayService {
})
} else {
await this.assertDeviceLimitNotReached(user._id)
deviceData.enabled = input.enabled ?? true
return await this.deviceModel.create(deviceData)
}
}