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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/47334-android-os-security-patch-level
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Android host OS versions now include the security patch level (e.g. "Android 16 (2026-05-01)"), so hosts are grouped by patch level on the OS versions page.
19 changes: 16 additions & 3 deletions server/mdm/android/service/pubsub.go
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De
host.Host.ComputerName = computerName
host.Host.Hostname = computerName
host.Host.Platform = "android"
host.Host.OSVersion = "Android " + device.SoftwareInfo.AndroidVersion
host.Host.OSVersion = "Android " + androidOSVersion(device.SoftwareInfo)
host.Host.Build = device.SoftwareInfo.AndroidBuildNumber
host.Host.Memory = device.MemoryInfo.TotalRam

Expand Down Expand Up @@ -833,14 +833,27 @@ func (svc *Service) updateHostOperatingSystem(ctx context.Context, hostID uint,
}
if err := svc.fleetDS.UpdateHostOperatingSystem(ctx, hostID, fleet.OperatingSystem{
Name: "Android",
Version: device.SoftwareInfo.AndroidVersion,
Version: androidOSVersion(device.SoftwareInfo),
Platform: "android",
}); err != nil {
return ctxerr.Wrap(ctx, err, "update Android host operating system")
}
return nil
}

// androidOSVersion folds the security patch level into the Android version,
// e.g. "16 (2026-05-01)". Older devices that don't report a patch level fall
// back to the bare major version ("16").
func androidOSVersion(si *androidmanagement.SoftwareInfo) string {
if si == nil {
return ""
}
if si.SecurityPatchLevel == "" {
return si.AndroidVersion
}
return si.AndroidVersion + " (" + si.SecurityPatchLevel + ")"
}

func getAndroidHostKey(device *androidmanagement.Device) string {
if device.HardwareInfo.EnterpriseSpecificId != "" {
return device.HardwareInfo.EnterpriseSpecificId
Expand Down Expand Up @@ -903,7 +916,7 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De
ComputerName: computerName,
Hostname: computerName,
Platform: "android",
OSVersion: "Android " + device.SoftwareInfo.AndroidVersion,
OSVersion: "Android " + androidOSVersion(device.SoftwareInfo),
Build: device.SoftwareInfo.AndroidBuildNumber,
Memory: device.MemoryInfo.TotalRam,
GigsTotalDiskSpace: gigsTotalDiskSpace,
Expand Down
67 changes: 62 additions & 5 deletions server/mdm/android/service/pubsub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ func TestPubSubEnrollment(t *testing.T) {
}

expectedHostID := uint(99)
var capturedHost *fleet.AndroidHost
mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) {
capturedHost = host
return &fleet.AndroidHost{Host: &fleet.Host{ID: expectedHostID}}, nil
}
var capturedHostID uint
Expand All @@ -288,12 +290,14 @@ func TestPubSubEnrollment(t *testing.T) {
}
enrollmentMessage := createEnrollmentMessage(t, deviceInfo)
// createEnrollmentMessage sets AndroidVersion="1"; override to a more
// realistic version so we verify it's passed through unchanged.
// realistic version + security patch level so we verify both are folded
// into the OS version as "16 (2026-05-01)".
data, err := base64.StdEncoding.DecodeString(enrollmentMessage.Data)
require.NoError(t, err)
var decoded androidmanagement.Device
require.NoError(t, json.Unmarshal(data, &decoded))
decoded.SoftwareInfo.AndroidVersion = "16"
decoded.SoftwareInfo.SecurityPatchLevel = "2026-05-01"
reEncoded, err := json.Marshal(decoded)
require.NoError(t, err)
enrollmentMessage.Data = base64.StdEncoding.EncodeToString(reEncoded)
Expand All @@ -304,8 +308,57 @@ func TestPubSubEnrollment(t *testing.T) {
require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked)
require.Equal(t, expectedHostID, capturedHostID)
require.Equal(t, "Android", capturedOS.Name)
require.Equal(t, "16", capturedOS.Version)
require.Equal(t, "16 (2026-05-01)", capturedOS.Version)
require.Equal(t, "android", capturedOS.Platform)
require.NotNil(t, capturedHost)
require.Equal(t, "Android 16 (2026-05-01)", capturedHost.Host.OSVersion)
})

t.Run("falls back to bare major version when no security patch level is reported", func(t *testing.T) {
mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
MDM: fleet.MDM{AndroidEnabledAndConfigured: true},
}, nil
}

expectedHostID := uint(100)
var capturedHost *fleet.AndroidHost
mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) {
capturedHost = host
return &fleet.AndroidHost{Host: &fleet.Host{ID: expectedHostID}}, nil
}
var capturedOS fleet.OperatingSystem
mockDS.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error {
capturedOS = hostOS
return nil
}

enrollmentToken := enrollmentTokenRequest{EnrollSecret: "global"}
enrollTokenData, err := json.Marshal(enrollmentToken)
require.NoError(t, err)
deviceInfo := androidmanagement.Device{
Name: createAndroidDeviceId("test-android-os-no-spl"),
EnrollmentTokenData: string(enrollTokenData),
}
enrollmentMessage := createEnrollmentMessage(t, deviceInfo)
// createEnrollmentMessage leaves SecurityPatchLevel empty; override the
// version and confirm no trailing "(...)" is appended.
data, err := base64.StdEncoding.DecodeString(enrollmentMessage.Data)
require.NoError(t, err)
var decoded androidmanagement.Device
require.NoError(t, json.Unmarshal(data, &decoded))
decoded.SoftwareInfo.AndroidVersion = "16"
reEncoded, err := json.Marshal(decoded)
require.NoError(t, err)
enrollmentMessage.Data = base64.StdEncoding.EncodeToString(reEncoded)

err = svc.ProcessPubSubPush(t.Context(), "value", enrollmentMessage)
require.NoError(t, err)

require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked)
require.Equal(t, "16", capturedOS.Version)
require.NotNil(t, capturedHost)
require.Equal(t, "Android 16", capturedHost.Host.OSVersion)
})

t.Run("creates device as company-owned if specified in enrollment message", func(t *testing.T) {
Expand Down Expand Up @@ -1000,7 +1053,8 @@ func TestStatusReportPopulatesOperatingSystem(t *testing.T) {
Model: "Pixel 8a",
},
SoftwareInfo: &androidmanagement.SoftwareInfo{
AndroidVersion: "16",
AndroidVersion: "16",
SecurityPatchLevel: "2026-05-01",
},
MemoryInfo: &androidmanagement.MemoryInfo{
TotalRam: int64(8 * 1024 * 1024 * 1024),
Expand All @@ -1020,7 +1074,9 @@ func TestStatusReportPopulatesOperatingSystem(t *testing.T) {
require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked)
require.Equal(t, expectedHostID, capturedHostID)
require.Equal(t, "Android", capturedOS.Name)
require.Equal(t, "16", capturedOS.Version)
// The security patch level is folded into the OS-version row so it is
// distinct per patch level (e.g. "16" vs "16 (2026-05-01)").
require.Equal(t, "16 (2026-05-01)", capturedOS.Version)
require.Equal(t, "android", capturedOS.Platform)
}

Expand Down Expand Up @@ -1206,6 +1262,7 @@ func TestUpdateHost(t *testing.T) {
SoftwareInfo: &androidmanagement.SoftwareInfo{
AndroidBuildNumber: "updated-build",
AndroidVersion: "15",
SecurityPatchLevel: "2026-05-01",
},
MemoryInfo: &androidmanagement.MemoryInfo{
TotalRam: int64(16 * 1024 * 1024 * 1024), // 16GB RAM
Expand Down Expand Up @@ -1240,7 +1297,7 @@ func TestUpdateHost(t *testing.T) {
require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.ComputerName)
require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.Hostname)
require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.HardwareModel)
require.Equal(t, "Android 15", capturedHost.Host.OSVersion)
require.Equal(t, "Android 15 (2026-05-01)", capturedHost.Host.OSVersion)
})

t.Run("UUID is set from EnterpriseSpecificId", func(t *testing.T) {
Expand Down
Loading