Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/vernu/sms/helpers/ApiErrorHelper.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
}
Expand Down
33 changes: 33 additions & 0 deletions api/src/gateway/gateway.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ describe('GatewayService', () => {

const mockBillingService = {
canPerformAction: jest.fn(),
getUserLimits: jest.fn(),
notifyDeviceLimitReached: jest.fn(),
}

const mockSmsQueueService = {
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions api/src/gateway/gateway.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export class GatewayService {
})
} else {
await this.assertDeviceLimitNotReached(user._id)
deviceData.enabled = input.enabled ?? true
return await this.deviceModel.create(deviceData)
}
}
Expand Down
Loading