Compare commits

...
1 Commits
Author SHA1 Message Date
geekouc 9327f9a7cb feat: add per-card cellular IMS SMS policy 2026-08-22 00:16:23 +08:00
18 changed files with 781 additions and 67 deletions
+71 -21
View File
@@ -496,26 +496,7 @@ func restoreConfiguredCellularData(
if err != nil { if err != nil {
continue continue
} }
networkRequest := device.NetworkRequest{ networkRequest := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
networkRequest.APN = policy.APN
if policy.IPVersion != "" {
networkRequest.IPVersion = policy.IPVersion
}
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
networkRequest.Username = profile.Username
networkRequest.Password = profile.Password
networkRequest.Authentication = profile.AuthType
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
networkRequest.IPVersion = profile.RoamingIPVersion
}
}
}
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second) dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest) _, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
cancel() cancel()
@@ -527,6 +508,40 @@ func restoreConfiguredCellularData(
} }
} }
func configuredCellularNetworkRequest(
ctx context.Context,
database *store.Store,
config store.Device,
snapshot *device.Snapshot,
) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if snapshot == nil {
return request
}
iccid := strings.TrimSpace(snapshot.ICCID)
policy, err := database.CardPolicy(ctx, iccid)
if err != nil {
return request
}
request.APN = policy.APN
if policy.IPVersion != "" {
request.IPVersion = policy.IPVersion
}
profile, err := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func disableAllDeveloperCellularData( func disableAllDeveloperCellularData(
ctx context.Context, ctx context.Context,
logger *slog.Logger, logger *slog.Logger,
@@ -1161,7 +1176,7 @@ func enforceDefaultSafeCardPolicy(
} }
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default", IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
}); err != nil { }); err != nil {
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err) logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
return return
@@ -1195,6 +1210,9 @@ func reconcileCardPolicies(
) { ) {
observedCards := make(map[string]string) observedCards := make(map[string]string)
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay) wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
imsApplied := make(map[string]string)
imsRetryAfter := make(map[string]time.Time)
imsDataRestorePending := make(map[string]bool)
reconcile := func() { reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx) policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil { if policyListErr == nil {
@@ -1241,6 +1259,38 @@ func reconcileCardPolicies(
if policyErr != nil { if policyErr != nil {
continue continue
} }
imsKey := fmt.Sprintf("%s:%t", iccid, policy.CellularIMSEnabled)
if policy.CellularIMSManaged && imsApplied[config.ID] != imsKey && !time.Now().Before(imsRetryAfter[config.ID]) {
imsContext, cancelIMS := context.WithTimeout(ctx, 45*time.Second)
status, imsErr := manager.SetCellularIMS(imsContext, entry.ID, policy.CellularIMSEnabled)
cancelIMS()
if imsErr != nil {
imsRetryAfter[config.ID] = time.Now().Add(time.Minute)
logger.Warn("reconcile cellular IMS policy failed", "device_id", config.ID, "iccid", iccid, "error", imsErr)
} else {
imsApplied[config.ID] = imsKey
delete(imsRetryAfter, config.ID)
logger.Info("reconciled cellular IMS policy", "device_id", config.ID, "iccid", iccid,
"enabled", policy.CellularIMSEnabled, "registered", status.Registered,
"changed", status.Changed, "rebooting", status.Rebooting)
if status.Rebooting {
imsDataRestorePending[config.ID] = config.NetworkEnabled && !config.VoWiFiEnabled
continue
}
}
}
if imsDataRestorePending[config.ID] && config.NetworkEnabled && !policy.VoWiFiEnabled && entry.Snapshot.PSAttached {
request := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
restoreContext, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
_, restoreErr := manager.SetNetwork(restoreContext, entry.ID, request)
cancelRestore()
if restoreErr != nil {
logger.Warn("reconcile cellular data after IMS reboot failed", "device_id", config.ID, "error", restoreErr)
continue
}
delete(imsDataRestorePending, config.ID)
logger.Info("restored cellular data after reconciled IMS reboot", "device_id", config.ID, "interface", config.Interface)
}
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) { if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true policy.AirplaneEnabled = true
policy.NetworkEnabled = false policy.NetworkEnabled = false
+158
View File
@@ -0,0 +1,158 @@
package device
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"vocat/internal/modem"
)
// CellularIMSStatus is the Quectel baseband IMS switch and current registration
// state. Configured is persistent module configuration; Registered is live and
// may remain false until the operator finishes IMS registration.
type CellularIMSStatus struct {
Supported bool `json:"supported"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
CSKnown bool `json:"csKnown"`
CSRegistered bool `json:"csRegistered"`
Changed bool `json:"changed,omitempty"`
Rebooting bool `json:"rebooting,omitempty"`
}
var cellularIMSLine = regexp.MustCompile(`(?i)^\+QCFG:\s*"ims"\s*,\s*([01])(?:\s*,\s*([01]))?\s*$`)
var cellularCSLine = regexp.MustCompile(`(?i)^\+CREG:\s*(?:\d+\s*,\s*)?([0-9]+)(?:\s*,.*)?$`)
func parseCellularCSRegistration(lines []string) (registered, known bool) {
for _, line := range lines {
matches := cellularCSLine.FindStringSubmatch(strings.TrimSpace(line))
if matches == nil {
continue
}
status, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
return status == 1 || status == 5, true
}
return false, false
}
func parseCellularIMSStatus(lines []string) (CellularIMSStatus, error) {
for _, line := range lines {
matches := cellularIMSLine.FindStringSubmatch(strings.TrimSpace(line))
if matches == nil {
continue
}
configured, _ := strconv.Atoi(matches[1])
registered := 0
if len(matches) > 2 && matches[2] != "" {
registered, _ = strconv.Atoi(matches[2])
}
return CellularIMSStatus{
Supported: true, Configured: configured == 1, Registered: registered == 1,
}, nil
}
return CellularIMSStatus{}, errors.New("modem did not return a Quectel IMS status")
}
func (manager *Manager) CellularIMS(ctx context.Context, id string) (CellularIMSStatus, error) {
state, err := manager.lookup(id)
if err != nil {
return CellularIMSStatus{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return CellularIMSStatus{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err := manager.readCellularIMS(ctx, client)
if err == nil {
if response, csErr := manager.command(ctx, client, "AT+CREG?"); csErr == nil {
status.CSRegistered, status.CSKnown = parseCellularCSRegistration(response.Lines)
}
}
manager.setResult(id, state, nil, err)
return status, err
}
// SetCellularIMS changes the persistent Quectel IMS override. A full modem
// restart is issued only when the configured value changes; this is essential
// because QCFG may report the new setting before the baseband has loaded it.
func (manager *Manager) SetCellularIMS(ctx context.Context, id string, enabled bool) (CellularIMSStatus, error) {
state, err := manager.lookup(id)
if err != nil {
return CellularIMSStatus{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return CellularIMSStatus{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err := manager.readCellularIMS(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
if status.Configured == enabled {
manager.setResult(id, state, nil, nil)
return status, nil
}
target := 0
if enabled {
target = 1
}
if _, err = manager.command(ctx, client, fmt.Sprintf(`AT+QCFG="ims",%d`, target)); err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status, err = manager.readCellularIMS(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
if status.Configured != enabled {
err = errors.New("modem did not retain the requested IMS setting")
manager.setResult(id, state, nil, err)
return CellularIMSStatus{}, err
}
status.Changed = true
status.Rebooting = true
rebootCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
_, err = client.Execute(rebootCtx, "AT+CFUN=1,1")
cancel()
if closeErr := client.Close(); err == nil {
err = closeErr
}
state.client = nil
state.preFlightMode = nil
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return status, err
}
func (manager *Manager) readCellularIMS(ctx context.Context, client modem.Client) (CellularIMSStatus, error) {
response, err := manager.command(ctx, client, `AT+QCFG="ims"`)
if err != nil {
return CellularIMSStatus{}, fmt.Errorf("query cellular IMS: %w", err)
}
status, err := parseCellularIMSStatus(response.Lines)
if err != nil {
return CellularIMSStatus{}, err
}
return status, nil
}
+80
View File
@@ -0,0 +1,80 @@
package device
import (
"context"
"testing"
"vocat/internal/modem"
)
func TestParseCellularIMSStatus(t *testing.T) {
for _, test := range []struct {
line string
configured, active bool
}{
{`+QCFG: "ims",0,0`, false, false},
{`+QCFG: "ims",1,0`, true, false},
{`+QCFG: "ims", 1, 1`, true, true},
{`+QCFG: "ims",1`, true, false},
} {
status, err := parseCellularIMSStatus([]string{test.line})
if err != nil || !status.Supported || status.Configured != test.configured || status.Registered != test.active {
t.Errorf("parseCellularIMSStatus(%q) = %+v, %v", test.line, status, err)
}
}
if _, err := parseCellularIMSStatus([]string{"OK"}); err == nil {
t.Fatal("missing QCFG status was accepted")
}
}
func TestParseCellularCSRegistration(t *testing.T) {
for _, test := range []struct {
line string
registered bool
}{
{`+CREG: 0,1`, true},
{`+CREG: 2,5,"1234","12345678",7`, true},
{`+CREG: 0,3`, false},
} {
registered, known := parseCellularCSRegistration([]string{test.line})
if !known || registered != test.registered {
t.Errorf("parseCellularCSRegistration(%q) = %t, %t", test.line, registered, known)
}
}
if _, known := parseCellularCSRegistration([]string{"OK"}); known {
t.Fatal("missing CREG status was accepted")
}
}
func TestSetCellularIMSEnablesAndRebootsOnlyOnce(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",0,0`}, Final: "OK"}},
{command: `AT+QCFG="ims",1`, response: modem.Response{Final: "OK"}},
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,0`}, Final: "OK"}},
{command: "AT+CFUN=1,1", response: modem.Response{Final: "OK"}},
}}
manager, id := newStartedTestManager(t, client)
status, err := manager.SetCellularIMS(context.Background(), id, true)
if err != nil || !status.Configured || !status.Changed || !status.Rebooting {
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
}
if client.closeCount != 1 {
t.Fatalf("close count = %d, want 1", client.closeCount)
}
client.assertDone(t)
}
func TestSetCellularIMSNoopDoesNotReboot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,1`}, Final: "OK"}},
}}
manager, id := newStartedTestManager(t, client)
status, err := manager.SetCellularIMS(context.Background(), id, true)
if err != nil || status.Changed || status.Rebooting || !status.Registered {
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
}
if client.closeCount != 0 {
t.Fatalf("close count = %d, want 0", client.closeCount)
}
client.assertDone(t)
}
+169 -1
View File
@@ -56,6 +56,11 @@ type DeviceController interface {
ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error) ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error)
} }
type cellularIMSController interface {
CellularIMS(context.Context, string) (device.CellularIMSStatus, error)
SetCellularIMS(context.Context, string, bool) (device.CellularIMSStatus, error)
}
type deviceConfigPayload struct { type deviceConfigPayload struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -282,7 +287,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
if errors.Is(policyErr, store.ErrNotFound) { if errors.Is(policyErr, store.ErrNotFound) {
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default", IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
}) })
} }
if policyErr != nil { if policyErr != nil {
@@ -614,6 +619,11 @@ func (s *Server) handleDevicePath(
return true return true
} }
return s.handleAPNProfiles(w, r, physicalID) return s.handleAPNProfiles(w, r, physicalID)
case "cellular-ims":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleCellularIMS(w, r, config, physicalID)
case "network/public-ip": case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) { if !s.requirePhysicalDevice(w, physicalPresent) {
return true return true
@@ -1389,6 +1399,164 @@ func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physi
return true return true
} }
func (s *Server) handleCellularIMS(
w http.ResponseWriter,
r *http.Request,
config store.Device,
physicalID string,
) bool {
controller, ok := s.devices.(cellularIMSController)
if !ok {
writeError(w, http.StatusNotImplemented, "cellular_ims_unsupported", "cellular IMS control is unavailable")
return true
}
entry, err := s.devices.Get(physicalID)
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady {
writeError(w, http.StatusConflict, "sim_not_ready", "a ready SIM is required to configure cellular IMS")
return true
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if !validICCID(iccid) {
writeError(w, http.StatusConflict, "iccid_unavailable", "the active SIM ICCID is unavailable")
return true
}
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
switch r.Method {
case http.MethodGet:
status, statusErr := controller.CellularIMS(r.Context(), physicalID)
if statusErr != nil {
writeError(w, http.StatusUnprocessableEntity, "cellular_ims_unsupported", statusErr.Error())
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": cellularIMSResponse(iccid, policy.CellularIMSEnabled, status)})
case http.MethodPatch:
var request struct {
Enabled *bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if request.Enabled == nil {
writeError(w, http.StatusBadRequest, "invalid_cellular_ims", "enabled is required")
return true
}
policy.CellularIMSEnabled = *request.Enabled
policy.CellularIMSManaged = true
policy.Source = "manual"
if policy.IPVersion == "" {
policy.IPVersion = "IPV4V6"
}
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return true
}
status, applyErr := controller.SetCellularIMS(r.Context(), physicalID, *request.Enabled)
if applyErr != nil {
writeError(w, http.StatusBadGateway, "cellular_ims_apply_failed", "IMS policy was saved but could not be applied: "+applyErr.Error())
return true
}
if status.Rebooting && config.NetworkEnabled && !config.VoWiFiEnabled {
s.restoreCellularDataAfterIMSReboot(config.ID, physicalID, iccid)
}
statusCode := http.StatusOK
if status.Rebooting {
statusCode = http.StatusAccepted
}
writeJSON(w, statusCode, map[string]any{"data": cellularIMSResponse(iccid, *request.Enabled, status)})
default:
w.Header().Set("Allow", "GET, PATCH")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return true
}
// restoreCellularDataAfterIMSReboot rebuilds the QMI/AT data session destroyed
// by AT+CFUN=1,1. The desired data state already lives in the device/card
// policy; this only waits for the same SIM to register again before replaying it.
func (s *Server) restoreCellularDataAfterIMSReboot(configID, physicalID, iccid string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var lastErr error
for {
select {
case <-ctx.Done():
s.logger.Warn("restore cellular data after IMS reboot timed out", "device_id", configID, "error", lastErr)
return
case <-ticker.C:
}
config, err := s.store.Device(ctx, configID)
if err != nil || !config.NetworkEnabled || config.VoWiFiEnabled {
return
}
entry, err := s.devices.Get(physicalID)
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady ||
!strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), iccid) ||
!entry.Snapshot.PSAttached {
lastErr = err
continue
}
request := s.cellularNetworkRequest(ctx, config, entry.Snapshot)
restoreCtx, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
_, err = s.devices.SetNetwork(restoreCtx, physicalID, request)
cancelRestore()
if err != nil {
lastErr = err
continue
}
s.logger.Info("restored cellular data after IMS reboot", "device_id", configID, "interface", config.Interface)
return
}
}()
}
func (s *Server) cellularNetworkRequest(ctx context.Context, config store.Device, snapshot *device.Snapshot) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: true, APN: strings.TrimSpace(config.APN), IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if snapshot == nil {
return request
}
iccid := strings.TrimSpace(snapshot.ICCID)
policy, err := s.store.CardPolicy(ctx, iccid)
if err != nil {
return request
}
request.APN = strings.TrimSpace(policy.APN)
if policy.IPVersion != "" {
request.IPVersion = policy.IPVersion
}
profile, err := s.store.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func cellularIMSResponse(iccid string, desired bool, status device.CellularIMSStatus) map[string]any {
return map[string]any{
"iccid": iccid, "desired_enabled": desired, "supported": status.Supported,
"configured": status.Configured, "registered": status.Registered,
"cs_known": status.CSKnown, "cs_registered": status.CSRegistered,
"changed": status.Changed, "rebooting": status.Rebooting,
}
}
func (s *Server) handleCellularData( func (s *Server) handleCellularData(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -73,6 +73,51 @@ func TestParseModemAPNProfiles(t *testing.T) {
} }
} }
type fakeCellularIMSController struct {
fakeDeviceController
status device.CellularIMSStatus
setTo *bool
setErr error
}
func (controller *fakeCellularIMSController) CellularIMS(context.Context, string) (device.CellularIMSStatus, error) {
return controller.status, controller.setErr
}
func (controller *fakeCellularIMSController) SetCellularIMS(_ context.Context, _ string, enabled bool) (device.CellularIMSStatus, error) {
controller.setTo = &enabled
return controller.status, controller.setErr
}
func TestCellularIMSPatchPersistsCurrentICCIDsPolicy(t *testing.T) {
test := newSettingsAPITest(t)
const iccid = "898520313000000590"
controller := &fakeCellularIMSController{
fakeDeviceController: fakeDeviceController{entry: device.Device{
ID: "physical-1", Discovered: true,
Snapshot: &device.Snapshot{DeviceID: "physical-1", SIMReady: true, ICCID: iccid},
}},
status: device.CellularIMSStatus{Supported: true, Configured: true, Changed: true, Rebooting: true},
}
test.server.devices = controller
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/devices/configured-1/cellular-ims", strings.NewReader(`{"enabled":true}`))
request.Header.Set("Content-Type", "application/json")
if !test.server.handleCellularIMS(recorder, request, store.Device{ID: "configured-1"}, "physical-1") {
t.Fatal("handleCellularIMS returned false")
}
if recorder.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
}
if controller.setTo == nil || !*controller.setTo {
t.Fatalf("SetCellularIMS captured %v", controller.setTo)
}
policy, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !policy.CellularIMSManaged || !policy.CellularIMSEnabled {
t.Fatalf("stored policy = %+v, %v", policy, err)
}
}
type esimAIDCaptureController struct { type esimAIDCaptureController struct {
fakeDeviceController fakeDeviceController
switchAID string switchAID string
+10 -1
View File
@@ -1410,13 +1410,15 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
APN *string `json:"apn"` APN *string `json:"apn"`
IPVersion *string `json:"ip_version"` IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"` CustomPhoneNumber *string `json:"custom_phone_number"`
CellularIMSEnabled *bool `json:"cellular_ims_enabled"`
} }
if err := s.decodeJSON(w, r, &request); err != nil { if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return return
} }
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil && if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil { request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil &&
request.CellularIMSEnabled == nil {
writeError( writeError(
w, w,
http.StatusBadRequest, http.StatusBadRequest,
@@ -1464,6 +1466,10 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
} }
policy.CustomPhoneNumber = phoneNumber policy.CustomPhoneNumber = phoneNumber
} }
if request.CellularIMSEnabled != nil {
policy.CellularIMSEnabled = *request.CellularIMSEnabled
policy.CellularIMSManaged = true
}
if request.VoWiFiEnabled != nil { if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled policy.VoWiFiEnabled = *request.VoWiFiEnabled
} }
@@ -1504,6 +1510,7 @@ func defaultCardPolicy(iccid string) store.CardPolicy {
AirplaneEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", IPVersion: "IPV4V6",
Source: "default", Source: "default",
CellularIMSManaged: true,
} }
} }
@@ -1797,6 +1804,8 @@ func cardPolicyResponse(policy store.CardPolicy) map[string]any {
"apn": policy.APN, "apn": policy.APN,
"ip_version": policy.IPVersion, "ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber, "custom_phone_number": policy.CustomPhoneNumber,
"cellular_ims_enabled": policy.CellularIMSEnabled,
"cellular_ims_managed": policy.CellularIMSManaged,
"source": policy.Source, "source": policy.Source,
} }
if !policy.CreatedAt.IsZero() { if !policy.CreatedAt.IsZero() {
+10 -1
View File
@@ -668,7 +668,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any) policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" || if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true || policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" { policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" ||
policy["cellular_ims_enabled"] != false || policy["cellular_ims_managed"] != true {
t.Fatalf("default policy = %#v", policy) t.Fatalf("default policy = %#v", policy)
} }
@@ -681,6 +682,14 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body) t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
} }
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"cellular_ims_enabled":true}`)
if recorder.Code != http.StatusOK {
t.Fatalf("cellular IMS policy status = %d, body = %s", recorder.Code, recorder.Body)
}
storedIMS, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !storedIMS.CellularIMSEnabled || !storedIMS.CellularIMSManaged {
t.Fatalf("stored cellular IMS policy = %+v, %v", storedIMS, err)
}
response = decodeSettingsResponse(t, recorder) response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any) policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" { if policy["custom_phone_number"] != "+8613800138000" {
+4 -4
View File
@@ -279,8 +279,8 @@ func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T)
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil { if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if version != 19 { if version != schemaVersion {
t.Fatalf("schema version = %d, want 19", version) t.Fatalf("schema version = %d, want %d", version, schemaVersion)
} }
for _, column := range []string{ for _, column := range []string{
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center", "ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
@@ -333,8 +333,8 @@ func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil { if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if version != 19 { if version != schemaVersion {
t.Fatalf("schema version = %d, want 19", version) t.Fatalf("schema version = %d, want %d", version, schemaVersion)
} }
} }
+9
View File
@@ -381,6 +381,15 @@ func migrationStatements(version int) []string {
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`, `ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`, `ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
} }
case 20:
return []string{
`ALTER TABLE card_policies
ADD COLUMN cellular_ims_enabled INTEGER NOT NULL DEFAULT 0
CHECK (cellular_ims_enabled IN (0, 1))`,
`ALTER TABLE card_policies
ADD COLUMN cellular_ims_managed INTEGER NOT NULL DEFAULT 0
CHECK (cellular_ims_managed IN (0, 1))`,
}
default: default:
return nil return nil
} }
+2
View File
@@ -483,6 +483,8 @@ type CardPolicy struct {
APN string APN string
IPVersion string IPVersion string
CustomPhoneNumber string CustomPhoneNumber string
CellularIMSEnabled bool
CellularIMSManaged bool
Source string Source string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
+14 -6
View File
@@ -386,8 +386,9 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies ( INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled, iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, custom_phone_number, source, created_at, updated_at apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled, network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled, vowifi_enabled = excluded.vowifi_enabled,
@@ -395,12 +396,15 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
apn = excluded.apn, apn = excluded.apn,
ip_version = excluded.ip_version, ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number, custom_phone_number = excluded.custom_phone_number,
cellular_ims_enabled = excluded.cellular_ims_enabled,
cellular_ims_managed = excluded.cellular_ims_managed,
source = excluded.source, source = excluded.source,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled), value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion, boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(), value.CustomPhoneNumber, boolInt(value.CellularIMSEnabled), boolInt(value.CellularIMSManaged), value.Source,
createdAt.Unix(), updatedAt.Unix(),
) )
if err != nil { if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err) return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
@@ -446,16 +450,18 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
const cardPolicySelect = ` const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled, SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, custom_phone_number, source, created_at, updated_at apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
source, created_at, updated_at
FROM card_policies` FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) { func cardPolicy(row rowScanner) (CardPolicy, error) {
var value CardPolicy var value CardPolicy
var networkEnabled, vowifiEnabled, airplaneEnabled int var networkEnabled, vowifiEnabled, airplaneEnabled, cellularIMSEnabled, cellularIMSManaged int
var createdAt, updatedAt int64 var createdAt, updatedAt int64
err := row.Scan( err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled, &value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt, &value.APN, &value.IPVersion, &value.CustomPhoneNumber, &cellularIMSEnabled, &cellularIMSManaged,
&value.Source, &createdAt, &updatedAt,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound return CardPolicy{}, ErrNotFound
@@ -466,6 +472,8 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
value.NetworkEnabled = networkEnabled != 0 value.NetworkEnabled = networkEnabled != 0
value.VoWiFiEnabled = vowifiEnabled != 0 value.VoWiFiEnabled = vowifiEnabled != 0
value.AirplaneEnabled = airplaneEnabled != 0 value.AirplaneEnabled = airplaneEnabled != 0
value.CellularIMSEnabled = cellularIMSEnabled != 0
value.CellularIMSManaged = cellularIMSManaged != 0
value.CreatedAt = time.Unix(createdAt, 0).UTC() value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC() value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil return value, nil
+1 -1
View File
@@ -14,7 +14,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const schemaVersion = 19 const schemaVersion = 20
var ErrNotFound = errors.New("store: not found") var ErrNotFound = errors.New("store: not found")
@@ -3,6 +3,7 @@ import { CardUiRegular } from "@fluentui/react-icons";
import { Button, Input, Tag, message } from "../ui"; import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard"; import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN"; import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles"; import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions"; import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types"; import type { CardPolicy } from "../../types";
@@ -141,6 +142,17 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.airplaneFailed} failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle} onToggle={toggles.onAirplaneToggle}
/> : null} /> : null}
{!wifiCallingOnly ? <CellularIMSPolicyCard
deviceId={deviceId}
iccid={iccid}
enabled={currentPolicy?.cellularImsEnabled ?? false}
managed={currentPolicy?.cellularImsManaged ?? false}
live={operable}
deviceOnline={deviceOnline}
vowifiEnabled={local.vowifiEnabled}
airplaneEnabled={local.airplaneEnabled}
onChanged={onPolicyChanged}
/> : null}
</div> </div>
{!wifiCallingOnly ? <CardPolicyAPN {!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId} deviceId={deviceId}
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from "react";
import { apiMessage } from "../../api";
import { useI18n } from "../../lib/i18n";
import { confirmDialog, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { getCellularIMS, setCellularIMS, updateCardPolicy } from "./deviceActions";
interface CellularIMSPolicyCardProps {
deviceId: string;
iccid: string;
enabled: boolean;
managed: boolean;
live: boolean;
deviceOnline: boolean;
vowifiEnabled: boolean;
airplaneEnabled: boolean;
compact?: boolean;
onChanged: () => void | Promise<void>;
}
export function CellularIMSPolicyCard({ deviceId, iccid, enabled, managed, live, deviceOnline, vowifiEnabled, airplaneEnabled, compact, onChanged }: CellularIMSPolicyCardProps) {
const { t } = useI18n();
const [local, setLocal] = useState(enabled);
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const [status, setStatus] = useState<Awaited<ReturnType<typeof getCellularIMS>> | null>(null);
useEffect(() => setLocal(enabled), [enabled, iccid]);
const loadStatus = useCallback(async () => {
if (!live) {
setStatus(null);
return;
}
try {
const status = await getCellularIMS(deviceId);
setStatus(status);
} catch {
setStatus(null);
}
}, [deviceId, live]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
const toggle = async (value: boolean) => {
if (pending || !deviceOnline) return;
const confirmed = await confirmDialog(
<div className="space-y-2">
{value && status?.registered ? (
<p>{t("当前蜂窝 IMS 已正常注册,通常无需强制启用;继续操作仍会改为强制模式。")}</p>
) : null}
{value && !status?.registered && status?.csKnown && status.csRegistered ? (
<p>{t("当前已注册 CS 域,短信可能正在通过 CS 正常工作;强制 IMS 后能否注册取决于运营商、漫游网络和 MBN。")}</p>
) : null}
{value && live && !status ? (
<p>{t("无法读取当前 CS/IMS 状态,继续后将直接尝试应用强制 IMS 配置。")}</p>
) : null}
{!value ? (
<p>{t("关闭此开关只会恢复 MBN/运营商默认 IMS 行为,并不保证蜂窝 IMS 会被禁用。")}</p>
) : null}
{!managed ? <p>{t("确认后 VoCat 将开始按此 ICCID 管理蜂窝 IMS 配置,切换其他卡时不会沿用本卡策略。")}</p> : null}
{vowifiEnabled ? (
<p>{t("当前 VoWiFi 已开启且蜂窝射频关闭;此设置会保存,但蜂窝 IMS 需在关闭 VoWiFi并恢复蜂窝射频后才能注册。")}</p>
) : airplaneEnabled ? (
<p>{t("当前处于飞行模式;此设置会保存,但蜂窝 IMS 需在关闭飞行模式后才能注册。")}</p>
) : null}
{!live ? <p>{t("此卡当前未激活或设备离线,配置将在此卡激活并上线后应用。")}</p> : null}
<p className="font-medium text-amber-700 dark:text-amber-300">
{live
? t("确认后将应用配置并重启模组,蜂窝数据、短信和通话可能短暂断联。")
: t("此卡激活后应用配置时会重启模组,蜂窝数据、短信和通话可能短暂断联。")}
</p>
</div>,
value ? t("确认强制启用蜂窝 IMS 短信") : t("确认恢复默认 IMS 行为"),
{ confirmText: value ? t("强制启用") : t("恢复默认"), type: "warning" },
);
if (!confirmed) return;
const previous = local;
setLocal(value);
setPending(true);
setFailed(false);
try {
if (live) {
const status = await setCellularIMS(deviceId, value);
setStatus(status);
if (status.rebooting) message.success(t("IMS 配置已保存,模组正在重启"));
else if (!status.changed) message.success(t("状态已一致,已跳过重启流程"));
} else {
await updateCardPolicy(iccid, { cellularImsEnabled: value });
message.success(t("IMS 配置已保存,将在此卡激活后生效"));
}
await onChanged();
} catch (error) {
setLocal(previous);
setFailed(true);
message.error(apiMessage(error) || t("蜂窝 IMS 配置失败"));
await onChanged();
} finally {
setPending(false);
}
};
return <PolicySwitchCard
compact={compact}
title={t("强制启用蜂窝 IMS 短信")}
subtitle={compact ? undefined : t("解决部分运营商无法收发短信的问题;该策略根据当前 ICCID/Profile 保存")}
tone="indigo"
checked={local}
disabled={pending || !deviceOnline}
pending={pending}
failed={failed}
onToggle={(value) => void toggle(value)}
/>;
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button, Spinner } from "../ui"; import { Button, Spinner } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard"; import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN"; import { CardPolicyAPN } from "./CardPolicyAPN";
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
import { useCardPolicyToggles } from "./useCardPolicyToggles"; import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions"; import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types"; import type { CardPolicy } from "../../types";
@@ -68,7 +69,7 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
) : ( ) : (
<> <>
{noteText ? <div className="text-[11px] text-amber-600 dark:text-amber-400">{noteText}</div> : null} {noteText ? <div className="text-[11px] text-amber-600 dark:text-amber-400">{noteText}</div> : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<PolicySwitchCard <PolicySwitchCard
compact compact
title="VoWiFi" title="VoWiFi"
@@ -87,6 +88,18 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
failed={toggles.airplaneFailed} failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle} onToggle={toggles.onAirplaneToggle}
/> />
<CellularIMSPolicyCard
compact
deviceId={deviceId}
iccid={iccid}
enabled={policy?.cellularImsEnabled ?? false}
managed={policy?.cellularImsManaged ?? false}
live={mode === "live"}
deviceOnline={deviceOnline}
vowifiEnabled={local.vowifiEnabled}
airplaneEnabled={local.airplaneEnabled}
onChanged={() => { void load(); onPolicyChanged(); }}
/>
</div> </div>
<CardPolicyAPN <CardPolicyAPN
deviceId={deviceId} deviceId={deviceId}
@@ -28,6 +28,25 @@ export interface CardPolicyUpdate {
apn?: string; apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6"; ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string; customPhoneNumber?: string;
cellularImsEnabled?: boolean;
}
export interface CellularIMSStatus {
iccid: string;
desiredEnabled: boolean;
supported: boolean;
configured: boolean;
registered: boolean;
csKnown: boolean;
csRegistered: boolean;
changed: boolean;
rebooting: boolean;
}
export function getCellularIMS(deviceId: string) {
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`);
}
export function setCellularIMS(deviceId: string, enabled: boolean) {
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`, { method: "PATCH", body: { enabled } });
} }
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) { export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body }); return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
@@ -37,12 +37,28 @@ export function useCardPolicyToggles(source: PolicyFlags | null, impl: PolicyTog
const localRef = useRef(local); const localRef = useRef(local);
localRef.current = local; localRef.current = local;
// CardPolicyPanel derives `source` inline, so its object identity changes on
// every render. Depend on the primitive fields instead; otherwise this
// effect updates local state forever and prevents route transitions from
// committing after the card-policy tab has mounted.
const sourceVoWiFiEnabled = source?.vowifiEnabled;
const sourceAirplaneEnabled = source?.airplaneEnabled;
useEffect(() => { useEffect(() => {
if (!source) return; if (sourceVoWiFiEnabled === undefined || sourceAirplaneEnabled === undefined) return;
setLocal({ vowifiEnabled: source.vowifiEnabled, airplaneEnabled: source.airplaneEnabled }); setLocal((current) => {
if (
current.vowifiEnabled === sourceVoWiFiEnabled &&
current.airplaneEnabled === sourceAirplaneEnabled
) return current;
return {
vowifiEnabled: sourceVoWiFiEnabled,
airplaneEnabled: sourceAirplaneEnabled,
};
});
setVowifiFailed(false); setVowifiFailed(false);
setAirplaneFailed(false); setAirplaneFailed(false);
}, [source]); }, [sourceVoWiFiEnabled, sourceAirplaneEnabled]);
async function toggle( async function toggle(
field: Field, field: Field,
+2
View File
@@ -273,6 +273,8 @@ export interface CardPolicy {
apn?: string; apn?: string;
ipVersion?: string; ipVersion?: string;
customPhoneNumber?: string; customPhoneNumber?: string;
cellularImsEnabled: boolean;
cellularImsManaged: boolean;
source?: string; source?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;