Compare commits

..
6 Commits
Author SHA1 Message Date
4a39ed3d31 feat: add per-card cellular IMS SMS policy (#77)
Co-authored-by: geekouc <[email protected]>
2026-08-22 02:37:35 +08:00
Meng MengandGitHub b2daa972d2 fix(ims): omit empty PANI call headers (#76)
* fix(ims): omit empty PANI call headers

* test(ims): exercise disabled PANI resolution
2026-08-21 23:15:58 +08:00
ihipopandGitHub 54288e5657 fix(ims): align VoWiFi SIP profile behavior (#75) 2026-08-21 22:48:55 +08:00
76af0784e1 fix: stabilize OpenStick 410 VoWiFi startup (#74)
* fix: stabilize OpenStick 410 VoWiFi startup

* fix: recover OpenStick 410 eUICC channel allocation

---------

Co-authored-by: MengMengCode <[email protected]>
2026-08-21 19:26:37 +08:00
06ea65558c fix: ignore non-voice CLCC records in call monitor (#71)
Co-authored-by: geekouc <[email protected]>
2026-08-21 15:44:00 +08:00
MengMengCode d26937f9eb Fix something 2026-08-21 12:25:16 +08:00
38 changed files with 1590 additions and 160 deletions
+134 -22
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,
@@ -684,7 +699,18 @@ func configureVoWiFiRuntime(
) )
} }
} }
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil { requestEnable := func() error {
_, requestErr := manager.RequestEnabled(deviceConfig.ID, true)
return requestErr
}
if err := requestVoWiFiStartup(
ctx,
logger,
deviceConfig.DeviceType,
deviceConfig.ID,
wifi410VoWiFiStartupDelay,
requestEnable,
); err != nil {
_ = manager.Close(context.Background()) _ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err) return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
} }
@@ -696,8 +722,55 @@ func configureVoWiFiRuntime(
const ( const (
vowifiStartupRadioAttempts = 3 vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second vowifiStartupRadioDelay = time.Second
wifi410VoWiFiStartupDelay = 80 * time.Second
) )
// requestVoWiFiStartup delays only the persisted startup policy for OpenStick
// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet
// period after a cold boot; user-triggered reconnects and every other device
// type continue to execute immediately.
func requestVoWiFiStartup(
ctx context.Context,
logger *slog.Logger,
deviceType string,
deviceID string,
delay time.Duration,
request func() error,
) error {
if deviceType != store.DeviceTypeWiFi410 || delay <= 0 {
return request()
}
if logger == nil {
logger = slog.Default()
}
logger.Info(
"OpenStick 410 VoWiFi startup delayed",
"device_id", deviceID,
"delay", delay,
)
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if err := request(); err != nil {
logger.Warn(
"OpenStick 410 delayed VoWiFi startup failed",
"device_id", deviceID,
"error", err,
)
}
}()
return nil
}
func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool {
return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore)
}
type flightModeSetter interface { type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error) SetFlight(context.Context, string, bool) (device.FlightResult, error)
} }
@@ -1103,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
@@ -1136,6 +1209,10 @@ func reconcileCardPolicies(
vowifiManager *vowifiruntime.Manager, vowifiManager *vowifiruntime.Manager,
) { ) {
observedCards := make(map[string]string) observedCards := make(map[string]string)
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 {
@@ -1182,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
@@ -1217,6 +1326,9 @@ func reconcileCardPolicies(
} }
switch { switch {
case stateErr != nil || !state.Enabled: case stateErr != nil || !state.Enabled:
if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) {
continue
}
_, _ = vowifiManager.RequestEnabled(config.ID, true) _, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid): case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID) _, _ = vowifiManager.RequestReconnect(config.ID)
+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)
}
+3
View File
@@ -439,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand
} }
const slot uint8 = 1 const slot uint8 = 1
logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid) logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid)
if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) {
logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid)
}
if err != nil { if err != nil {
_ = session.Close() _ = session.Close()
return nil, fmt.Errorf("%w: %v", errNoEUICC, err) return nil, fmt.Errorf("%w: %v", errNoEUICC, err)
+57 -1
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/iniwex5/quectel-qmi-go/pkg/qmi"
) )
func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error { func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error {
@@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe
func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) { func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
channel, openErr := session.OpenLogicalChannel(ctx, 1, aid) channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid)
if openErr != nil { if openErr != nil {
return fmt.Errorf("open QMI UIM logical channel: %w", openErr) return fmt.Errorf("open QMI UIM logical channel: %w", openErr)
} }
@@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai
return return
} }
type nativeQMIChannelRecoverySession interface {
OpenLogicalChannel(context.Context, uint8, []byte) (byte, error)
PowerOffSIM(context.Context, uint8) error
PowerOnSIM(context.Context, uint8) error
}
// OpenStick 410 can leave the physical UICC powered but unable to allocate a
// logical channel after a SIM hot-swap. A UIM service reset alone does not
// clear that state; cycling the affected physical slot does. Recover only the
// precise QMI InsufficientResources response, then retry the original AID once.
func openNativeQMIChannelWithRecovery(
ctx context.Context,
session nativeQMIChannelRecoverySession,
slot uint8,
aid []byte,
) (byte, error) {
channel, err := session.OpenLogicalChannel(ctx, slot, aid)
if err == nil || !isQMIInsufficientResources(err) {
return channel, err
}
if resetter, ok := session.(nativeQMIUIMResetSession); ok {
_ = resetter.ResetUIM(ctx)
}
if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil {
return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr))
}
if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil {
return 0, errors.Join(err, waitErr)
}
return session.OpenLogicalChannel(ctx, slot, aid)
}
func isQMIInsufficientResources(err error) bool {
qmiErr := qmi.GetQMIError(err)
return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044
}
var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) { func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) {
err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error {
qmiMode, modeErr := session.GetOperatingMode(ctx) qmiMode, modeErr := session.GetOperatingMode(ctx)
+11 -5
View File
@@ -325,11 +325,7 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
} }
calls := parseCLCC(response) calls := parseCLCC(response)
for _, call := range calls { for _, call := range calls {
direction, _ := call["direction"].(int) if isIncomingVoiceCLCC(call) {
state, _ := call["state"].(int)
// direction 1 = incoming (Mobile Terminated)
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
caller, _ := call["number"].(string) caller, _ := call["number"].(string)
if caller == "" { if caller == "" {
caller = "未知号码" caller = "未知号码"
@@ -351,3 +347,13 @@ func (s *Server) pollCellularCalls(ctx context.Context) {
} }
} }
} }
func isIncomingVoiceCLCC(call map[string]any) bool {
direction, _ := call["direction"].(int)
state, _ := call["state"].(int)
mode, _ := call["mode"].(int)
// direction 1 = incoming (Mobile Terminated)
// mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3)
}
@@ -4,6 +4,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"vocat/internal/modem"
) )
func TestIncomingCallNotificationTextFormatting(t *testing.T) { func TestIncomingCallNotificationTextFormatting(t *testing.T) {
@@ -61,6 +63,56 @@ func TestIncomingCallDeduplication(t *testing.T) {
} }
} }
func TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) {
tests := []struct {
name string
call map[string]any
want bool
}{
{
name: "incoming voice ringing",
call: map[string]any{"direction": 1, "state": 4, "mode": 0},
want: true,
},
{
name: "incoming voice active",
call: map[string]any{"direction": 1, "state": 0, "mode": 0},
want: true,
},
{
name: "incoming packet data active",
call: map[string]any{"direction": 1, "state": 0, "mode": 1},
want: false,
},
{
name: "outgoing voice alerting",
call: map[string]any{"direction": 0, "state": 3, "mode": 0},
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isIncomingVoiceCLCC(test.call); got != test.want {
t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want)
}
})
}
// EC20/EC25 firmware may expose an active packet-data session in CLCC.
// It must not be treated as an incoming voice call.
dataCalls := parseCLCC(modem.Response{
Lines: []string{`+CLCC: 1,1,0,1,0,"",128`},
Final: "OK",
})
if len(dataCalls) != 1 {
t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls))
}
if isIncomingVoiceCLCC(dataCalls[0]) {
t.Fatal("active packet-data CLCC record was treated as an incoming voice call")
}
}
func TestRenderCallWebhookTemplate(t *testing.T) { func TestRenderCallWebhookTemplate(t *testing.T) {
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC) now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
message := IncomingCallNotification{ message := IncomingCallNotification{
+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
+28 -19
View File
@@ -1405,18 +1405,20 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut: case http.MethodPut:
var request struct { var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"` VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"` AirplaneEnabled *bool `json:"airplane_enabled"`
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
} }
@@ -1499,11 +1505,12 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
func defaultCardPolicy(iccid string) store.CardPolicy { func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{ return store.CardPolicy{
ICCID: strings.TrimSpace(iccid), ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true, VoWiFiEnabled: true,
AirplaneEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", IPVersion: "IPV4V6",
Source: "default", Source: "default",
CellularIMSManaged: true,
} }
} }
@@ -1790,14 +1797,16 @@ func normalizeCustomPhoneNumber(value string) (string, error) {
func cardPolicyResponse(policy store.CardPolicy) map[string]any { func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{ response := map[string]any{
"iccid": policy.ICCID, "iccid": policy.ICCID,
"network_enabled": false, "network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled, "vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled, "airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN, "apn": policy.APN,
"ip_version": policy.IPVersion, "ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber, "custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source, "cellular_ims_enabled": policy.CellularIMSEnabled,
"cellular_ims_managed": policy.CellularIMSManaged,
"source": policy.Source,
} }
if !policy.CreatedAt.IsZero() { if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt response["created_at"] = policy.CreatedAt
+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
} }
+12 -10
View File
@@ -476,16 +476,18 @@ type LogFilter struct {
} }
type CardPolicy struct { type CardPolicy struct {
ICCID string ICCID string
NetworkEnabled bool NetworkEnabled bool
VoWiFiEnabled bool VoWiFiEnabled bool
AirplaneEnabled bool AirplaneEnabled bool
APN string APN string
IPVersion string IPVersion string
CustomPhoneNumber string CustomPhoneNumber string
Source string CellularIMSEnabled bool
CreatedAt time.Time CellularIMSManaged bool
UpdatedAt time.Time Source string
CreatedAt time.Time
UpdatedAt time.Time
} }
type CardAPNProfile struct { type CardAPNProfile struct {
+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")
+33 -19
View File
@@ -42,8 +42,10 @@ type CarrierProfile struct {
IMSRegisterProfile string IMSRegisterProfile string
IMSIPSecEncryption string IMSIPSecEncryption string
SMSCenter string SMSCenter string
PANIEnabled *bool
PANICountry string PANICountry string
PANINode string PANINode string
IMSUserAgent string
IMSDialURIScheme string IMSDialURIScheme string
IMSUserEqPhone bool IMSUserEqPhone bool
IMSVoiceCodecs []string IMSVoiceCodecs []string
@@ -57,7 +59,6 @@ type IMSRegisterOptions struct {
ContactExtraTags []string ContactExtraTags []string
SupportedHeader *string SupportedHeader *string
AllowHeader *string AllowHeader *string
UserAgent string
PPreferredIdentity bool PPreferredIdentity bool
PVisitedNetworkID string PVisitedNetworkID string
PAccessNetworkInfo *string PAccessNetworkInfo *string
@@ -68,6 +69,7 @@ type IMSRegisterOptions struct {
const ( const (
IMSContactFormatStandard = "standard" IMSContactFormatStandard = "standard"
IMSContactFormatATT = "att" IMSContactFormatATT = "att"
IMSContactFormatGSMA = "gsma"
) )
type carrierProfileDocument struct { type carrierProfileDocument struct {
@@ -76,13 +78,13 @@ type carrierProfileDocument struct {
} }
type carrierProfileRule struct { type carrierProfileRule struct {
ID string `json:"id"` ID string `json:"id"`
Match carrierProfileMatch `json:"match,omitzero"` Match carrierProfileMatch `json:"match,omitzero"`
MatchAny []carrierProfileMatch `json:"match_any,omitempty"` MatchAny []carrierProfileMatch `json:"match_any,omitempty"`
Route carrierProfileRoute `json:"route,omitzero"` Route carrierProfileRoute `json:"route,omitzero"`
EPDG carrierProfileEPDG `json:"epdg,omitzero"` EPDG carrierProfileEPDG `json:"epdg,omitzero"`
IKE carrierProfileIKE `json:"ike,omitzero"` IKE carrierProfileIKE `json:"ike,omitzero"`
IMS carrierProfileIMS `json:"ims,omitzero"` IMS carrierProfileIMS `json:"ims,omitzero"`
} }
type carrierProfileMatch struct { type carrierProfileMatch struct {
@@ -116,8 +118,10 @@ type carrierProfileIMS struct {
RegisterProfile string `json:"register_profile,omitempty"` RegisterProfile string `json:"register_profile,omitempty"`
IPSecEncryption string `json:"ipsec_encryption,omitempty"` IPSecEncryption string `json:"ipsec_encryption,omitempty"`
SMSCenter string `json:"sms_center,omitempty"` SMSCenter string `json:"sms_center,omitempty"`
PANIEnabled *bool `json:"pani_enabled,omitempty"`
PANICountry string `json:"pani_country,omitempty"` PANICountry string `json:"pani_country,omitempty"`
PANINode string `json:"pani_node,omitempty"` PANINode string `json:"pani_node,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DialURIScheme string `json:"dial_uri_scheme,omitempty"` DialURIScheme string `json:"dial_uri_scheme,omitempty"`
UserEqPhone *bool `json:"user_eq_phone,omitempty"` UserEqPhone *bool `json:"user_eq_phone,omitempty"`
VoiceCodecs []string `json:"voice_codecs,omitempty"` VoiceCodecs []string `json:"voice_codecs,omitempty"`
@@ -131,7 +135,6 @@ type carrierProfileRegisterOptions struct {
ContactExtraTags []string `json:"contact_extra_tags,omitempty"` ContactExtraTags []string `json:"contact_extra_tags,omitempty"`
SupportedHeader *string `json:"supported_header,omitempty"` SupportedHeader *string `json:"supported_header,omitempty"`
AllowHeader *string `json:"allow_header,omitempty"` AllowHeader *string `json:"allow_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
PPreferredIdentity bool `json:"p_preferred_identity,omitempty"` PPreferredIdentity bool `json:"p_preferred_identity,omitempty"`
PVisitedNetworkID string `json:"p_visited_network_id,omitempty"` PVisitedNetworkID string `json:"p_visited_network_id,omitempty"`
PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"` PAccessNetworkInfo *string `json:"p_access_network_info,omitempty"`
@@ -322,6 +325,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false return false
} }
if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" && if country := strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)); country != "" &&
country != "AUTO" &&
(len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') { (len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z') {
return false return false
} }
@@ -340,7 +344,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false return false
} }
if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" && if format := strings.ToLower(strings.TrimSpace(rule.IMS.RegisterOptions.ContactFormat)); format != "" &&
format != IMSContactFormatStandard && format != IMSContactFormatATT { format != IMSContactFormatStandard && format != IMSContactFormatATT && format != IMSContactFormatGSMA {
return false return false
} }
for _, value := range rule.IMS.RegisterOptions.ContactExtraTags { for _, value := range rule.IMS.RegisterOptions.ContactExtraTags {
@@ -353,7 +357,7 @@ func validCarrierProfileRule(rule carrierProfileRule) bool {
return false return false
} }
} }
for _, value := range []string{rule.IMS.RegisterOptions.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} { for _, value := range []string{rule.IMS.UserAgent, rule.IMS.RegisterOptions.PVisitedNetworkID, rule.IMS.RegisterOptions.CellularNetworkInfo} {
if strings.ContainsAny(value, "\r\n") { if strings.ContainsAny(value, "\r\n") {
return false return false
} }
@@ -479,8 +483,12 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
}{ }{
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI}, {name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID}, {name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true}, // GID values identify an MVNO/service profile within a host network and
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true}, // therefore outrank the host issuer's broad ICCID prefix. Otherwise a
// home-PLMN+ICCID AT&T rule hides RedPocket/Cricket/etc. even when the SIM
// exposes the carrier bundle's exact GID selector.
{name: "gid1", weight: 90, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
{name: "gid2", weight: 85, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
} { } {
if len(selector.values) == 0 { if len(selector.values) == 0 {
continue continue
@@ -584,8 +592,15 @@ func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, sourc
base.IMSIPSecEncryption = value base.IMSIPSecEncryption = value
} }
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter) base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
if rule.IMS.PANIEnabled != nil {
enabled := *rule.IMS.PANIEnabled
base.PANIEnabled = &enabled
}
base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry)) base.PANICountry = strings.ToUpper(strings.TrimSpace(rule.IMS.PANICountry))
base.PANINode = strings.TrimSpace(rule.IMS.PANINode) base.PANINode = strings.TrimSpace(rule.IMS.PANINode)
if value := strings.TrimSpace(rule.IMS.UserAgent); value != "" {
base.IMSUserAgent = value
}
if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" { if value := strings.ToLower(strings.TrimSpace(rule.IMS.DialURIScheme)); value != "" {
base.IMSDialURIScheme = value base.IMSDialURIScheme = value
} }
@@ -620,9 +635,6 @@ func applyRegisterOptions(base IMSRegisterOptions, rule carrierProfileRegisterOp
value := strings.TrimSpace(*rule.AllowHeader) value := strings.TrimSpace(*rule.AllowHeader)
base.AllowHeader = &value base.AllowHeader = &value
} }
if value := strings.TrimSpace(rule.UserAgent); value != "" {
base.UserAgent = value
}
if rule.PPreferredIdentity { if rule.PPreferredIdentity {
base.PPreferredIdentity = true base.PPreferredIdentity = true
} }
@@ -719,8 +731,8 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.ICCID) != "" { if strings.TrimSpace(identity.ICCID) != "" {
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok { if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
imsiCountry := countryCodeForMCC(identity.HomeMCC) imsiCountry := CountryCodeForMCC(identity.HomeMCC)
iccidCountry := countryCodeForMCC(mcc) iccidCountry := CountryCodeForMCC(mcc)
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) { if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
identity.HomeMCC = mcc identity.HomeMCC = mcc
identity.HomeMNC = mnc identity.HomeMNC = mnc
@@ -733,7 +745,9 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
return identity return identity
} }
func countryCodeForMCC(mcc string) string { // CountryCodeForMCC returns the ISO 3166-1 alpha-2 country code associated
// with an MCC known to the carrier compatibility database.
func CountryCodeForMCC(mcc string) string {
switch strings.TrimSpace(mcc) { switch strings.TrimSpace(mcc) {
case "515": case "515":
return "PH" return "PH"
+38
View File
@@ -46,6 +46,31 @@ func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
} }
} }
func TestResolveCarrierProfileGiffgaffIMSHeaders(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
})
options := profile.IMSRegisterOptions
if profile.IMSTransport != "tcp" || options.ContactFormat != IMSContactFormatGSMA {
t.Fatalf("giffgaff IMS transport/contact profile = %#v", profile)
}
if profile.IMSUserAgent != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff User-Agent = %q", profile.IMSUserAgent)
}
if options.SupportedHeader != nil || options.AllowHeader != nil {
t.Fatalf("giffgaff REGISTER header overrides = supported=%v allow=%v", options.SupportedHeader, options.AllowHeader)
}
if options.PAccessNetworkInfo != nil {
t.Fatalf("giffgaff unexpectedly defines a carrier PANI override = %v", *options.PAccessNetworkInfo)
}
if profile.PANIEnabled == nil || !*profile.PANIEnabled || profile.PANICountry != "AUTO" {
t.Fatalf("giffgaff PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if len(options.ContactExtraTags) != 2 || options.ContactExtraTags[0] != "+g.3gpp.mid-call" || options.ContactExtraTags[1] != "+g.3gpp.smsip" {
t.Fatalf("giffgaff Contact tags = %#v", options.ContactExtraTags)
}
}
func TestResolveCarrierProfileATT(t *testing.T) { func TestResolveCarrierProfileATT(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{ profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
@@ -55,6 +80,16 @@ func TestResolveCarrierProfileATT(t *testing.T) {
} }
} }
func TestResolveCarrierProfileRedPocketOutranksBroadATTICCID(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8901410000000000001", IMSI: "310170000000001",
HomeMCC: "310", HomeMNC: "170", SPN: "Red Pocket", GID1: "42FFFF",
})
if profile.ID != "ipcc-redpocket-310170" || profile.MatchSource != "hplmn+gid1" {
t.Fatalf("RedPocket profile = %#v", profile)
}
}
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) { func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"}) profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard { if profile.ID != CarrierProfileStandard {
@@ -69,6 +104,9 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
if profile.IMSRegisterOptions.SupportedHeader != nil { if profile.IMSRegisterOptions.SupportedHeader != nil {
t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader) t.Fatalf("standard supported header = %v", *profile.IMSRegisterOptions.SupportedHeader)
} }
if profile.PANIEnabled != nil || profile.PANICountry != "" {
t.Fatalf("standard PANI behavior = enabled=%v country=%q", profile.PANIEnabled, profile.PANICountry)
}
if profile.AllowSMSWithoutContactConfirmation { if profile.AllowSMSWithoutContactConfirmation {
t.Fatal("standard profile should require SMS contact confirmation") t.Fatal("standard profile should require SMS contact confirmation")
} }
+5 -4
View File
@@ -635,6 +635,11 @@ func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ip
warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec") warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec")
} }
} }
if strings.EqualFold(plistString(signaling.value["CountryOfOriginationFormat"]), "PANI") {
enabled := true
rule.IMS.PANIEnabled = &enabled
rule.IMS.PANICountry = "AUTO"
}
} }
} }
if useIPSec { if useIPSec {
@@ -661,10 +666,6 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath) warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"): case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath) warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
case key == "countryoforiginationformat":
// PANI is access/session metadata, not a carrier location constant.
// The IMS runtime provides one globally and consistently across
// REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed.
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"): case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath) warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
} }
+3
View File
@@ -76,6 +76,9 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
if rule.IMS.IPSecEncryption != "aes-cbc" { if rule.IMS.IPSecEncryption != "aes-cbc" {
t.Fatalf("converted IMS profile = %#v", rule.IMS) t.Fatalf("converted IMS profile = %#v", rule.IMS)
} }
if rule.IMS.PANIEnabled == nil || !*rule.IMS.PANIEnabled || rule.IMS.PANICountry != "AUTO" {
t.Fatalf("converted PANI behavior = enabled=%v country=%q", rule.IMS.PANIEnabled, rule.IMS.PANICountry)
}
for _, code := range []string{ for _, code := range []string{
"remote_certificate_bypass_ignored", "remote_certificate_bypass_ignored",
"disabled_dpd_ignored", "disabled_dpd_ignored",
+32 -1
View File
@@ -570,6 +570,14 @@
{ {
"id": "ipcc-redpocket-310170", "id": "ipcc-redpocket-310170",
"match_any": [ "match_any": [
{
"home_plmns": [
"310170"
],
"gid1_prefixes": [
"42"
]
},
{ {
"home_plmns": [ "home_plmns": [
"310410" "310410"
@@ -585,6 +593,18 @@
"gid1_prefixes": [ "gid1_prefixes": [
"42" "42"
] ]
},
{
"home_plmns": [
"310170",
"310410",
"310280"
],
"spns": [
"Red Pocket",
"RedPocket",
"Red Pocket Mobile"
]
} }
], ],
"epdg": { "epdg": {
@@ -5766,7 +5786,18 @@
"proposal": "modern" "proposal": "modern"
}, },
"ims": { "ims": {
"ipsec_encryption": "aes-cbc" "transport": "tcp",
"ipsec_encryption": "aes-cbc",
"pani_enabled": true,
"pani_country": "AUTO",
"user_agent": "iOS/18.6.2 iPhone",
"register_options": {
"contact_format": "gsma",
"contact_extra_tags": [
"+g.3gpp.mid-call",
"+g.3gpp.smsip"
]
}
} }
}, },
{ {
+82 -1
View File
@@ -29,6 +29,7 @@ var (
const ( const (
usimAIDPrefix = "A0000000871002" usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004" isimAIDPrefix = "A0000000871004"
efDIRFileID = 0x2f00
efADDecimal = 28589 // 0x6FAD efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN) efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second channelCleanupTimeout = 3 * time.Second
@@ -1025,6 +1026,19 @@ func (adapter *EC20Adapter) discoverAKAApplication(
} }
} }
} }
// CUAD is optional and is rejected by a number of EC20 firmware branches.
// In that case do not immediately fall back to the seven-byte registered
// application-provider prefix: cards may expose multiple USIM instances and
// require the complete PIX from EF_DIR to select the provisioned one. Read
// EF_DIR over the standards-based basic channel, which remains available on
// the same firmware that rejects CCHO/CGLA.
if discovered, discoverErr := adapter.discoverBasicApplicationAID(
ctx,
deviceID,
usimAIDPrefix,
); discoverErr == nil {
return discovered, "USIM", nil
}
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a // AT+CUAD is optional on older EC20 firmware. CCHO still provides a
// standards-based, evidence-bearing probe of the assigned USIM AID. // standards-based, evidence-bearing probe of the assigned USIM AID.
@@ -1053,6 +1067,64 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication(
return aidPrefix, application, nil return aidPrefix, application, nil
} }
func (adapter *EC20Adapter) discoverBasicApplicationAID(
ctx context.Context,
deviceID string,
aidPrefix string,
) (string, error) {
selectFile := func(fileID uint16) error {
apdu := []byte{
0x00, 0xa4, 0x00, 0x04, 0x02,
byte(fileID >> 8), byte(fileID), 0x00,
}
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
if err != nil {
return err
}
_, status, err := splitAPDUStatus(raw)
if err != nil {
return err
}
if status != 0x9000 {
return fmt.Errorf("vocat: EC20 basic-channel SELECT returned %04X", status)
}
return nil
}
if err := selectFile(0x3f00); err != nil {
return "", fmt.Errorf("select EC20 MF for application discovery: %w", err)
}
if err := selectFile(efDIRFileID); err != nil {
return "", fmt.Errorf("select EC20 EF_DIR for application discovery: %w", err)
}
for record := 1; record <= 32; record++ {
raw, err := adapter.transmitBasicAPDU(
ctx,
deviceID,
[]byte{0x00, 0xb2, byte(record), 0x04, 0x00},
false,
)
if err != nil {
return "", fmt.Errorf("read EC20 EF_DIR record %d: %w", record, err)
}
body, status, err := splitAPDUStatus(raw)
if err != nil {
return "", err
}
if status == 0x6a83 || status == 0x9402 {
break
}
if status != 0x9000 {
continue
}
for _, candidate := range collectApplicationAIDs(body) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, nil
}
}
}
return "", ErrEC20ApplicationAbsent
}
func (adapter *EC20Adapter) openLogicalChannel( func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context, ctx context.Context,
deviceID string, deviceID string,
@@ -1174,8 +1246,17 @@ func (adapter *EC20Adapter) transmitBasicAPDU(
if err != nil { if err != nil {
return nil, err return nil, err
} }
collected = append(collected, body...)
sw1 := byte(status >> 8) sw1 := byte(status >> 8)
if sw1 == 0x6c {
// The UICC knows the exact response length. Retry the original APDU
// with the advised Le without retaining the procedure response.
if len(current) < 5 {
return nil, errors.New("vocat: EC20 APDU cannot apply corrected response length")
}
current[len(current)-1] = byte(status)
continue
}
collected = append(collected, body...)
if sw1 != 0x61 && sw1 != 0x9f { if sw1 != 0x61 && sw1 != 0x9f {
collected = append(collected, byte(status>>8), byte(status)) collected = append(collected, byte(status>>8), byte(status))
return collected, nil return collected, nil
+53
View File
@@ -263,6 +263,59 @@ func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
} }
} }
func TestEC20AdapterDiscoversFullUSIMAIDFromEFDIRWhenCUADFails(t *testing.T) {
t.Parallel()
const fullAID = "A0000000871002FFFFFFFF8903020000"
record := "61184F10" + fullAID + "50045553494D"
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CSIM=%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
selectApplication := `AT+CSIM=42,"00A4040410` + fullAID + `"`
transcript := &ec20Transcript{
t: t,
steps: append(
identityTranscriptStepsWithoutEFAD("310280000000001"),
[]ec20TranscriptStep{
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: "AT+CUAD", err: errors.New("+CME ERROR: 13"), final: "+CME ERROR: 13"},
{command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}},
{command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 4,"6C1A"`}},
{command: `AT+CSIM=10,"00B201041A"`, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s9000"`, len(record)+4, record)}},
{command: `AT+CCHO="` + fullAID + `"`, err: errors.New("unsupported"), final: "ERROR"},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: "AT+CCID", lines: []string{"+CCID: 8944101234567890123"}},
{command: selectApplication, lines: []string{`+CSIM: 4,"9000"`}},
{command: authCommand, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}},
}...,
),
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
t.Fatalf("CheckReady: %v", err)
}
if _, err := adapter.Authenticate(context.Background(), identity, challenge); err != nil {
t.Fatalf("Authenticate: %v", err)
}
transcript.assertDone()
}
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse( func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
t *testing.T, t *testing.T,
) { ) {
+5 -1
View File
@@ -36,8 +36,12 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
var systemErr error var systemErr error
for _, targetHost := range hostsToTry { for _, targetHost := range hostsToTry {
addresses, err := resolver.LookupIPAddr(ctx, targetHost) ips, err := resolver.LookupIP(ctx, "ip4", targetHost)
if err == nil { if err == nil {
addresses := make([]net.IPAddr, 0, len(ips))
for _, ip := range ips {
addresses = append(addresses, net.IPAddr{IP: ip})
}
valid := filterValidPublicEPDGAddresses(addresses) valid := filterValidPublicEPDGAddresses(addresses)
if len(valid) > 0 { if len(valid) > 0 {
return valid, nil return valid, nil
+3 -1
View File
@@ -393,9 +393,11 @@ func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated") return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated")
} }
payloadLength := int(binary.BigEndian.Uint16(packet[4:6])) payloadLength := int(binary.BigEndian.Uint16(packet[4:6]))
if payloadLength+40 != len(packet) { declaredLength := payloadLength + 40
if declaredLength > len(packet) {
return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid") return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid")
} }
packet = packet[:declaredLength]
metadata := innerPacketMetadata{ metadata := innerPacketMetadata{
source: append(net.IP(nil), packet[8:24]...), source: append(net.IP(nil), packet[8:24]...),
destination: append(net.IP(nil), packet[24:40]...), destination: append(net.IP(nil), packet[24:40]...),
+20
View File
@@ -318,6 +318,26 @@ func TestParseInnerIPv6ESP(t *testing.T) {
} }
} }
func TestParseInnerIPv6ESPTrimsTrailingAlignmentBytes(t *testing.T) {
t.Parallel()
packet := make([]byte, 40+20+4)
packet[0] = 0x60
binary.BigEndian.PutUint16(packet[4:6], 20)
packet[6] = 6
packet[7] = 64
copy(packet[8:24], net.ParseIP("2001:db8::1").To16())
copy(packet[24:40], net.ParseIP("2001:db8::2").To16())
binary.BigEndian.PutUint16(packet[40:42], 49686)
binary.BigEndian.PutUint16(packet[42:44], 5060)
metadata, err := parseInnerPacket(packet)
if err != nil {
t.Fatal(err)
}
if metadata.protocol != 6 || metadata.sourcePort != 49686 || metadata.destinationPort != 5060 {
t.Fatalf("metadata = %+v", metadata)
}
}
func mustDefaultESPTunnel(t *testing.T) *espTunnel { func mustDefaultESPTunnel(t *testing.T) *espTunnel {
t.Helper() t.Helper()
return mustTestESPTunnel( return mustTestESPTunnel(
+31 -17
View File
@@ -115,8 +115,10 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
"P-Preferred-Identity: <"+preferredIdentity+">", "P-Preferred-Identity: <"+preferredIdentity+">",
"P-Preferred-Service: "+mmtelServiceURN, "P-Preferred-Service: "+mmtelServiceURN,
`Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`, `Accept-Contact: *;+g.3gpp.icsi-ref="`+mmtelFeatureTag+`"`,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(), )
"User-Agent: "+session.callUserAgent(), lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO", "Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE, PRACK, UPDATE, INFO",
"Supported: 100rel, timer, replaces", "Supported: 100rel, timer, replaces",
"Session-Expires: 1800;refresher=uac", "Session-Expires: 1800;refresher=uac",
@@ -499,8 +501,10 @@ func (session *Session) sendRejectedInviteACK(call *imsCall, response *sipRespon
"To: "+to, "To: "+to,
"Call-ID: "+call.callID, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d ACK", call.cseq), fmt.Sprintf("CSeq: %d ACK", call.cseq),
"P-Access-Network-Info: "+session.pAccessNetworkInfo(), )
"User-Agent: "+session.callUserAgent(), lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "", "Content-Length: 0", "", "",
) )
session.writeMu.Lock() session.writeMu.Lock()
@@ -577,8 +581,10 @@ func (session *Session) sendPRACK(call *imsCall, response *sipResponse) {
lines = append(lines, lines = append(lines,
"From: "+from, "To: "+to, "Call-ID: "+call.callID, "From: "+from, "To: "+to, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq, fmt.Sprintf("CSeq: %d PRACK", cseq), "RAck: "+rseq+" "+inviteCSeq,
"P-Access-Network-Info: "+session.pAccessNetworkInfo(), )
"User-Agent: "+session.callUserAgent(), lines = session.appendPAccessNetworkInfoHeader(lines)
lines = append(lines,
"User-Agent: "+session.imsUserAgent(),
"Content-Length: 0", "", "", "Content-Length: 0", "", "",
) )
ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second) ctx, cancel := context.WithTimeout(session.refreshContext, 10*time.Second)
@@ -709,10 +715,10 @@ func (session *Session) buildDialogRequest(call *imsCall, method string, cseq ui
"Call-ID: "+call.callID, "Call-ID: "+call.callID,
fmt.Sprintf("CSeq: %d %s", cseq, method), fmt.Sprintf("CSeq: %d %s", cseq, method),
"Supported: 100rel, timer", "Supported: 100rel, timer",
"User-Agent: "+session.callUserAgent(), "User-Agent: "+session.imsUserAgent(),
) )
if method != "CANCEL" { if method != "CANCEL" {
lines = append(lines, "P-Access-Network-Info: "+session.pAccessNetworkInfo()) lines = session.appendPAccessNetworkInfoHeader(lines)
} }
if method == "UPDATE" { if method == "UPDATE" {
lines = append(lines, session.dialogContactHeader()) lines = append(lines, session.dialogContactHeader())
@@ -771,6 +777,13 @@ func (session *Session) dialogContactHeader() string {
if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" { if session == nil || session.conn == nil || strings.TrimSpace(session.identity.user) == "" {
return "" return ""
} }
if session.imsRegisterOptions().ContactFormat == vowifi.IMSContactFormatGSMA {
contact := "Contact: <sip:" + session.contactAddress() + `>;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"`
if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"`
}
return contact
}
contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">" contact := "Contact: <sip:" + session.identity.user + "@" + session.contactAddress() + ";transport=" + session.transport + ">"
if strings.TrimSpace(session.instanceID) != "" { if strings.TrimSpace(session.instanceID) != "" {
contact += `;+sip.instance="<` + session.instanceID + `>"` contact += `;+sip.instance="<` + session.instanceID + `>"`
@@ -811,19 +824,20 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr
} }
func (session *Session) pAccessNetworkInfo() string { func (session *Session) pAccessNetworkInfo() string {
if session.paniResolved { if session == nil {
return ueProvidedPANI(session.pani) return ""
} }
return sessionPAccessNetworkInfo(session.instanceID) if session.paniResolved {
return session.pani
}
return resolveSessionPAccessNetworkInfo(session.request.Identity, session.imsLogger())
} }
func (session *Session) callUserAgent() string { func (session *Session) appendPAccessNetworkInfoHeader(lines []string) []string {
if session != nil && session.provider != nil { if pani := session.pAccessNetworkInfo(); pani != "" {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" { return append(lines, "P-Access-Network-Info: "+pani)
return value
}
} }
return "vocat/1" return lines
} }
func callResponseDiagnostic(response *sipResponse) string { func callResponseDiagnostic(response *sipResponse) string {
+53 -1
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"io" "io"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -229,7 +231,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\r\n", "P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n", "P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n", `Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
"P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n", "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode + "\r\n",
"User-Agent: VoCat Test\r\n", "User-Agent: VoCat Test\r\n",
"Accept: application/sdp\r\n", "Accept: application/sdp\r\n",
} { } {
@@ -239,6 +241,56 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
} }
} }
func TestDialogRequestOmitsPAccessNetworkInfoWhenProfileDisablesPANI(t *testing.T) {
profileDir := t.TempDir()
profile := `{"version":1,"profiles":[{"id":"test-pani-disabled","match":{"home_plmns":["00101"]},"ims":{"pani_enabled":false}}]}`
if err := os.WriteFile(filepath.Join(profileDir, "pani-disabled.json"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
emptyProfileDir := t.TempDir()
t.Cleanup(func() {
if err := vowifi.LoadCarrierProfileDirectory(emptyProfileDir); err != nil {
t.Errorf("clear external carrier profiles: %v", err)
}
})
if err := vowifi.LoadCarrierProfileDirectory(profileDir); err != nil {
t.Fatal(err)
}
identity := vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01", IMSI: "001010123456789"}
pani := resolveSessionPAccessNetworkInfo(identity, nil)
if pani != "" {
t.Fatalf("disabled profile PANI = %q, want empty", pani)
}
client, peer := net.Pipe()
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("close client connection: %v", err)
}
})
t.Cleanup(func() {
if err := peer.Close(); err != nil {
t.Errorf("close peer connection: %v", err)
}
})
session := &Session{
request: vowifi.IMSRequest{Identity: identity},
transport: "tcp",
conn: client,
}
call := &imsCall{
target: "sip:[email protected]",
from: "<sip:[email protected]>;tag=local",
to: "<sip:[email protected]>;tag=remote",
callID: "pani-disabled-call",
}
request := string(session.buildDialogRequest(call, "BYE", 2))
if strings.Contains(request, "\r\nP-Access-Network-Info:") {
t.Fatalf("BYE contains disabled P-Access-Network-Info header:\n%s", request)
}
}
func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) { func TestCallOriginatingIdentitiesFallBackToRegisteredIMPU(t *testing.T) {
session := &Session{ session := &Session{
identity: identitySet{ identity: identitySet{
+128 -26
View File
@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -24,6 +23,7 @@ const (
defaultRegistrationExpiry = 3600 * time.Second defaultRegistrationExpiry = 3600 * time.Second
defaultTransactionTimeout = 12 * time.Second defaultTransactionTimeout = 12 * time.Second
maxAuthenticationChallenges = 3 maxAuthenticationChallenges = 3
defaultPANIWLANNode = "ffffffffffff"
) )
var ( var (
@@ -649,6 +649,11 @@ func newSession(
if err != nil { if err != nil {
return nil, err return nil, err
} }
instanceURI := "urn:uuid:" + instanceID
profile := vowifi.ResolveCarrierProfile(request.Identity)
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
instanceURI = sipInstanceID(request.Identity, instanceID)
}
refreshContext, refreshCancel := context.WithCancel(context.Background()) refreshContext, refreshCancel := context.WithCancel(context.Background())
session := &Session{ session := &Session{
provider: provider, provider: provider,
@@ -660,8 +665,8 @@ func newSession(
conn: connection, conn: connection,
callID: callToken + "@" + addressHost(connection.LocalAddr()), callID: callToken + "@" + addressHost(connection.LocalAddr()),
fromTag: fromTag, fromTag: fromTag,
instanceID: "urn:uuid:" + instanceID, instanceID: instanceURI,
pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID), pani: resolveSessionPAccessNetworkInfo(request.Identity, provider.config.Logger),
paniResolved: true, paniResolved: true,
cseq: 1, cseq: 1,
refreshContext: refreshContext, refreshContext: refreshContext,
@@ -970,11 +975,7 @@ func (session *Session) buildRegister(
allow = *registerOptions.AllowHeader allow = *registerOptions.AllowHeader
} }
userAgent := strings.TrimSpace(session.provider.config.UserAgent) userAgent := session.imsUserAgent()
if override := strings.TrimSpace(registerOptions.UserAgent); override != "" &&
(userAgent == "" || userAgent == "vocat/1") {
userAgent = override
}
lines := []string{ lines := []string{
"REGISTER " + requestURI + " SIP/2.0", "REGISTER " + requestURI + " SIP/2.0",
@@ -1064,6 +1065,15 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
`%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`, `%s%s;audio;+g.3gpp.smsip;+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
base, extra, icsiRef, instanceID, base, extra, icsiRef, instanceID,
) )
case vowifi.IMSContactFormatGSMA:
extra := ""
for _, tag := range registerOptions.ContactExtraTags {
extra += ";" + tag
}
return fmt.Sprintf(
`<sip:%s>;+g.3gpp.icsi-ref="%s"%s;+sip.instance="<%s>"`,
contactAddress, icsiRef, extra, instanceID,
)
default: default:
extra := "" extra := ""
for _, tag := range registerOptions.ContactExtraTags { for _, tag := range registerOptions.ContactExtraTags {
@@ -1076,41 +1086,127 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi
} }
} }
// sessionPAccessNetworkInfo creates a syntactically valid, locally // sipInstanceID uses the standardized GSMA device-instance URI when a valid
// administered unicast WLAN node identifier from the already-random SIP // modem identity is available and keeps the generated UUID as the fallback.
// instance ID. It discloses neither a real BSSID nor subscriber identity, but func sipInstanceID(identity vowifi.SIMIdentity, fallback string) string {
// remains stable for every transaction belonging to this IMS registration. imei := strings.TrimSpace(identity.IMEI)
func sessionPAccessNetworkInfo(instanceID string) string { if len(imei) == 15 {
instanceID = strings.TrimSpace(instanceID) valid := true
if instanceID == "" { for _, digit := range imei {
return "" if digit < '0' || digit > '9' {
valid = false
break
}
}
if valid {
return "urn:gsma:imei:" + imei + "-0"
}
} }
digest := sha256.Sum256([]byte(instanceID)) return "urn:uuid:" + strings.TrimSpace(fallback)
digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast }
return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6])
func (session *Session) imsRegisterOptions() vowifi.IMSRegisterOptions {
if session == nil {
return vowifi.IMSRegisterOptions{}
}
return vowifi.ResolveCarrierProfile(session.request.Identity).IMSRegisterOptions
}
func (session *Session) imsUserAgent() string {
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
if value != "vocat/1" {
return value
}
}
}
if session != nil {
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
if value := strings.TrimSpace(profile.IMSUserAgent); value != "" {
return value
}
}
if session != nil && session.provider != nil {
if value := strings.TrimSpace(session.provider.config.UserAgent); value != "" {
return value
}
}
return "vocat/1"
}
func (session *Session) imsLogger() *slog.Logger {
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
return session.provider.config.Logger
}
return slog.Default()
} }
// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS // resolveSessionPAccessNetworkInfo freezes the selected value when the IMS
// session is created. This prevents a carrier-profile reload from changing // session is created. This prevents a carrier-profile reload from changing
// access identity between REGISTER, SMS MESSAGE and its RP-ACK. // access identity between REGISTER, SMS MESSAGE and its RP-ACK.
func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string { func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, logger *slog.Logger) string {
if logger == nil {
logger = slog.Default()
}
profile := vowifi.ResolveCarrierProfile(identity) profile := vowifi.ResolveCarrierProfile(identity)
if profile.PANIEnabled != nil && !*profile.PANIEnabled {
return ""
}
if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil { if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil {
return ueProvidedPANI(*configured) return appendPaniCountry(ueProvidedPANI(*configured), identity, profile, logger)
} }
node := strings.ToLower(strings.TrimSpace(profile.PANINode)) node := strings.ToLower(strings.TrimSpace(profile.PANINode))
if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 { if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 {
node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=") node = defaultPANIWLANNode
} }
if node == "" { if node == "" {
return "" return ""
} }
value := "IEEE-802.11;i-wlan-node-id=" + node value := "IEEE-802.11;i-wlan-node-id=" + node
if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { return appendPaniCountry(value, identity, profile, logger)
value += ";country=" + country }
func appendPaniCountry(value string, identity vowifi.SIMIdentity, profile vowifi.CarrierProfile, logger *slog.Logger) string {
value = strings.TrimSpace(value)
if value == "" {
return value
} }
return value parts := strings.Split(value, ";")
for _, parameter := range parts {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(parameter)), "country=") {
return value
}
}
countryMode := strings.ToUpper(strings.TrimSpace(profile.PANICountry))
country := countryMode
if countryMode == "AUTO" {
mcc := strings.TrimSpace(identity.HomeMCC)
if mcc == "" {
mcc = strings.TrimSpace(profile.RouteMCC)
}
country = vowifi.CountryCodeForMCC(mcc)
if country == "" {
if logger == nil {
logger = slog.Default()
}
logger.Error("IMS PANI country code could not be derived",
"category", "ims",
"stage", "pani_country",
"carrier_profile", profile.ID,
"mcc", mcc,
)
return value
}
}
if country == "" {
return value
}
parts = append(parts, "")
copy(parts[2:], parts[1:])
parts[1] = "country=" + country
return strings.Join(parts, ";")
} }
// ueProvidedPANI removes the network-provided marker from a profile override. // ueProvidedPANI removes the network-provided marker from a profile override.
@@ -1263,6 +1359,7 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
serviceRoutes := splitHeaderValues(response.values("Service-Route")) serviceRoutes := splitHeaderValues(response.values("Service-Route"))
registeredContact := "" registeredContact := ""
smsConfirmed := false smsConfirmed := false
profile := vowifi.ResolveCarrierProfile(session.request.Identity)
instanceLower := strings.ToLower(session.instanceID) instanceLower := strings.ToLower(session.instanceID)
contactURILower := strings.ToLower(fmt.Sprintf( contactURILower := strings.ToLower(fmt.Sprintf(
"sip:%s@%s;transport=%s", "sip:%s@%s;transport=%s",
@@ -1270,10 +1367,15 @@ func (session *Session) applyRegistrationEvidence(response *sipResponse) error {
session.contactAddress(), session.contactAddress(),
session.transport, session.transport,
)) ))
contactAddressLower := ""
if profile.IMSRegisterOptions.ContactFormat == vowifi.IMSContactFormatGSMA {
contactAddressLower = strings.ToLower("sip:" + session.contactAddress())
}
for _, contact := range contacts { for _, contact := range contacts {
lower := strings.ToLower(contact) lower := strings.ToLower(contact)
matchesThisSession := strings.Contains(lower, instanceLower) || matchesThisSession := strings.Contains(lower, instanceLower) ||
strings.Contains(lower, contactURILower) strings.Contains(lower, contactURILower) ||
(contactAddressLower != "" && strings.Contains(lower, contactAddressLower))
if matchesThisSession { if matchesThisSession {
registeredContact = contact registeredContact = contact
smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip") smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip")
+134 -13
View File
@@ -506,13 +506,8 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
} }
func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) { func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001" defaultPANI := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
first := sessionPAccessNetworkInfo(instanceID) if err := validateTestPANI(defaultPANI); err != nil {
second := sessionPAccessNetworkInfo(instanceID)
if first != second {
t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second)
}
if err := validateTestPANI(first); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" { if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" {
@@ -521,23 +516,149 @@ func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) {
if got := ueProvidedPANI("network-provided"); got != "" { if got := ueProvidedPANI("network-provided"); got != "" {
t.Fatalf("marker-only PANI = %q, want empty", got) t.Fatalf("marker-only PANI = %q, want empty", got)
} }
if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" { if got := (&Session{pani: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode, paniResolved: true}).pAccessNetworkInfo(); got != "IEEE-802.11;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("explicitly omitted session PANI = %q, want empty", got) t.Fatalf("session PANI = %q, want default WLAN node", got)
}
}
func TestPAccessNetworkInfoUsesDefaultNodeAndConditionalCountry(t *testing.T) {
cases := []struct {
name string
identity vowifi.SIMIdentity
want string
}{
{
name: "standard without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "giffgaff with IPCC PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
want: "IEEE-802.11;country=GB;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "AT&T without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
{
name: "VOXI without PANI country format",
identity: vowifi.SIMIdentity{IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI"},
want: "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
got := resolveSessionPAccessNetworkInfo(test.identity, slog.New(slog.NewTextHandler(io.Discard, nil)))
if got != test.want {
t.Fatalf("PANI = %q, want %q", got, test.want)
}
})
}
}
func TestAppendPaniCountryModes(t *testing.T) {
base := "IEEE-802.11;i-wlan-node-id=" + defaultPANIWLANNode
identity := vowifi.SIMIdentity{HomeMCC: "234"}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{}, slog.Default()); got != base {
t.Fatalf("empty PANI country = %q, want %q", got, base)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "GB"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("fixed PANI country = %q", got)
}
if got := appendPaniCountry(base, identity, vowifi.CarrierProfile{PANICountry: "AUTO"}, slog.Default()); got != "IEEE-802.11;country=GB;i-wlan-node-id="+defaultPANIWLANNode {
t.Fatalf("automatic PANI country = %q", got)
}
var logs strings.Builder
logger := slog.New(slog.NewTextHandler(&logs, nil))
got := appendPaniCountry(base, vowifi.SIMIdentity{}, vowifi.CarrierProfile{ID: "test-auto", PANICountry: "AUTO"}, logger)
if got != base || !strings.Contains(logs.String(), "IMS PANI country code could not be derived") {
t.Fatalf("failed automatic PANI country = %q, logs = %q", got, logs.String())
}
}
func TestIMSProfileUserAgentUsesUnifiedHeaderValue(t *testing.T) {
giffgaff := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
}}}
if got := giffgaff.imsUserAgent(); got != "iOS/18.6.2 iPhone" {
t.Fatalf("giffgaff IMS User-Agent = %q", got)
}
if options := giffgaff.imsRegisterOptions(); options.AllowHeader != nil || options.SupportedHeader != nil {
t.Fatalf("giffgaff REGISTER capability overrides leaked from business headers: %#v", options)
}
standard := &Session{request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
}}}
if got := standard.imsUserAgent(); got != "vocat/1" {
t.Fatalf("standard IMS User-Agent fallback = %q", got)
}
}
func TestSipInstanceIDUsesGSMAFormWhenIMEIIsAvailable(t *testing.T) {
identity := vowifi.SIMIdentity{IMEI: "353024112557010"}
if got := sipInstanceID(identity, "00000000-0000-4000-8000-000000000001"); got != "urn:gsma:imei:353024112557010-0" {
t.Fatalf("sipInstanceID() = %q", got)
}
if got := sipInstanceID(vowifi.SIMIdentity{IMEI: "not-an-imei"}, "00000000-0000-4000-8000-000000000001"); got != "urn:uuid:00000000-0000-4000-8000-000000000001" {
t.Fatalf("sipInstanceID() fallback = %q", got)
}
}
func TestGSMAContactFormatUsesAddressAndDeviceInstance(t *testing.T) {
session := &Session{
identity: identitySet{user: "234105776448519"},
transport: "tcp",
instanceID: "urn:gsma:imei:353024112557010-0",
}
got := session.buildContact("[2001:db8::1]:49686", vowifi.IMSRegisterOptions{
ContactFormat: vowifi.IMSContactFormatGSMA,
ContactExtraTags: []string{"+g.3gpp.mid-call", "+g.3gpp.smsip"},
})
want := `<sip:[2001:db8::1]:49686>;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel";+g.3gpp.mid-call;+g.3gpp.smsip;+sip.instance="<urn:gsma:imei:353024112557010-0>"`
if got != want {
t.Fatalf("GSMA Contact = %q, want %q", got, want)
} }
} }
func validateTestPANI(value string) error { func validateTestPANI(value string) error {
const prefix = "IEEE-802.11;i-wlan-node-id=" const accessType = "IEEE-802.11"
if !strings.HasPrefix(value, prefix) { if !strings.HasPrefix(value, accessType+";") {
return fmt.Errorf("value %q does not start with %q", value, prefix) return fmt.Errorf("value %q does not start with %q", value, accessType+";")
} }
if strings.Contains(strings.ToLower(value), "network-provided") { if strings.Contains(strings.ToLower(value), "network-provided") {
return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value) return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value)
} }
node, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) var nodeValue, country string
for _, parameter := range strings.Split(strings.TrimPrefix(value, accessType+";"), ";") {
key, parameterValue, ok := strings.Cut(parameter, "=")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "i-wlan-node-id":
nodeValue = strings.TrimSpace(parameterValue)
case "country":
country = strings.TrimSpace(parameterValue)
}
}
if nodeValue == "" {
return fmt.Errorf("i-wlan-node-id is missing: %q", value)
}
node, err := hex.DecodeString(nodeValue)
if err != nil || len(node) != 6 { if err != nil || len(node) != 6 {
return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value) return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value)
} }
if strings.EqualFold(nodeValue, defaultPANIWLANNode) {
if country != "" && len(country) != 2 {
return fmt.Errorf("country must be an ISO alpha-2 code: %q", value)
}
return nil
}
if node[0]&0x03 != 0x02 { if node[0]&0x03 != 0x02 {
return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value) return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value)
} }
+1
View File
@@ -1177,6 +1177,7 @@ func (session *Session) sendSIPMessageWith(
lines = append(lines, lines = append(lines,
"Request-Disposition: no-fork", "Request-Disposition: no-fork",
"Allow: MESSAGE", "Allow: MESSAGE",
"User-Agent: "+session.imsUserAgent(),
) )
if inReplyTo != "" { if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo) lines = append(lines, "In-Reply-To: "+inReplyTo)
+1 -1
View File
@@ -144,7 +144,7 @@ func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context,
} }
raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge)) raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge))
if err != nil { if err != nil {
return AKAResult{}, ErrEC20AKACommand return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err)
} }
return parseUSIMAuthenticateResponse(raw) return parseUSIMAuthenticateResponse(raw)
} }
@@ -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;