mirror of
https://github.com/vernu/textbee.git
synced 2026-06-27 22:35:46 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -133,6 +133,7 @@ export class GatewayService {
|
||||
})
|
||||
} else {
|
||||
await this.assertDeviceLimitNotReached(user._id)
|
||||
deviceData.enabled = input.enabled ?? true
|
||||
return await this.deviceModel.create(deviceData)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user