diff --git a/android/app/src/main/java/com/vernu/sms/helpers/ApiErrorHelper.kt b/android/app/src/main/java/com/vernu/sms/helpers/ApiErrorHelper.kt new file mode 100644 index 0000000..f8a3142 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/ApiErrorHelper.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt index 2c097ff..1fb7378 100644 --- a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt @@ -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 } diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt index bf953ec..ed27479 100644 --- a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt @@ -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 = 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) } } diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt index f0d6c4c..341221d 100644 --- a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt @@ -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." } diff --git a/api/src/gateway/gateway.service.spec.ts b/api/src/gateway/gateway.service.spec.ts index 44bda8e..7f43cd8 100644 --- a/api/src/gateway/gateway.service.spec.ts +++ b/api/src/gateway/gateway.service.spec.ts @@ -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', () => { diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 79d4fce..fe396c9 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -133,6 +133,7 @@ export class GatewayService { }) } else { await this.assertDeviceLimitNotReached(user._id) + deviceData.enabled = input.enabled ?? true return await this.deviceModel.create(deviceData) } }