Initial commit: PawletOS vendor tree

Adds the full vendor/pawlet tree for PawletOS on Raspberry Pi 4/5:

- build/: envsetup.sh helpers (breakfast/brunch/mka), bacon OTA target,
  Soong namespace, build core hook
- config/: common.mk, version.mk, BoardConfigPawlet.mk, BoardConfigSoong.mk,
  privapp-permissions, me.pawlet.android feature XML
- hal/boot_control/rpi/: AIDL boot_control HAL for RPi A/B slot switching
- apps/Updater/: A/B OTA updater (download, verify, install, reboot)
- apps/PawletPlatform/: platform resource APK (device icons, strings)
- overlay/frameworks/base/core/res/: framework config overrides
- prebuilt/, vars/: init rc, aosp_target_release
This commit is contained in:
2026-03-21 19:08:46 -07:00
commit 9344e608f6
53 changed files with 3302 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Normalize line endings to LF for all text files (Android build system requires LF)
* text=auto eol=lf
# Binaries — no line ending conversion
*.png binary
*.jpg binary
*.jar binary
*.zip binary
*.so binary
+10
View File
@@ -0,0 +1,10 @@
#
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
LOCAL_PATH := $(call my-dir)
# Pull in every Android.mk under this vendor tree.
include $(call all-makefiles-under,$(LOCAL_PATH))
+65
View File
@@ -0,0 +1,65 @@
PawletOS vendor tree — Third-party notices
==========================================
All files in this repository are licensed under the Apache License, Version 2.0
unless stated otherwise. You may obtain a copy of the License at:
https://www.apache.org/licenses/LICENSE-2.0
The following third-party works are incorporated, adapted, or used as the basis
for code in this repository. Each is also licensed under the Apache License,
Version 2.0, and their original copyright notices are retained in the
respective source files.
1. The LineageOS Project / The CyanogenMod Project
---------------------------------------------------
Files adapted from android_vendor_lineage:
- build/envsetup.sh (breakfast, brunch, mka, cmka, cout,
aospremote, repopick, reposync, repodiff)
- build/tasks/bacon.mk (bacon OTA package target)
- config/version.mk (buildtype filtering, version string structure)
Files in the Updater application whose architecture is derived from
android_packages_apps_Updater (packages/apps/Updater):
- apps/Updater/src/.../ABUpdateInstaller.kt
- apps/Updater/src/.../controller/UpdaterController.kt
- apps/Updater/src/.../DownloadClient.kt
- apps/Updater/src/.../OTAUtils.kt
- apps/Updater/src/.../UpdaterReceiver.kt
- apps/Updater/src/.../UpdaterService.kt
- apps/Updater/src/.../UpdatesActivity.kt
- apps/Updater/src/.../model/Update.kt
- apps/Updater/src/.../model/UpdateEntry.kt
- apps/Updater/res/
The update server JSON API is compatible with the LineageOS update server
API format as documented at:
https://github.com/LineageOS/android_packages_apps_Updater
Copyright (C) 2012 The CyanogenMod Project
Copyright (C) 2017 The LineageOS Project
Licensed under the Apache License, Version 2.0.
2. The Android Open Source Project (AOSP)
------------------------------------------
The boot control HAL in hal/boot_control/rpi/ implements the AOSP AIDL
interface android.hardware.boot (hardware/interfaces/boot/), and its
structure follows the AOSP reference implementation.
- hal/boot_control/rpi/BootControl.h
- hal/boot_control/rpi/BootControl.cpp
- hal/boot_control/rpi/service.cpp
- hal/boot_control/rpi/misc_rpi.h
- hal/boot_control/rpi/Android.bp
- hal/boot_control/rpi/android.hardware.boot-service.pawlet_rpi.rc
- hal/boot_control/rpi/android.hardware.boot-service.pawlet_rpi.xml
Build system hook files (build/core/config.mk, Android.mk files, soong
Android.bp files) follow AOSP vendor integration conventions.
Copyright (C) The Android Open Source Project
Licensed under the Apache License, Version 2.0.
+27
View File
@@ -0,0 +1,27 @@
// Copyright (C) 2025 oxmc / PawletOS
//
// SPDX-License-Identifier: Apache-2.0
//
// PawletOS platform resource package.
//
// exports_package_resources: true means any app can access these resources via:
// context.createPackageContext("me.pawlet.android", 0).getResources()
// or directly in XML via the package prefix:
// @me.pawlet.android:string/pawlet_device_name_rpi4
// @me.pawlet.android:drawable/ic_pawlet_device_rpi5
android_app {
name: "PawletPlatform",
srcs: [], // resource-only APK — no Java/Kotlin sources
resource_dirs: ["res"],
manifest: "AndroidManifest.xml",
// Make resources publicly addressable by other apps
export_package_resources: true,
platform_apis: true,
certificate: "platform",
privileged: false, // No privileged permissions needed — resources only
product_specific: true,
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.pawlet.android">
<uses-sdk
android:minSdkVersion="36"
android:targetSdkVersion="36" />
<application
android:label="PawletOS Platform"
android:allowBackup="false"
android:hasCode="false" />
</manifest>
@@ -0,0 +1,53 @@
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Device icon: Raspberry Pi 4 / 4 Model B
Stylised single-board computer outline at 24x24dp.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Board outline -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF4CAF50"
android:strokeWidth="1.5"
android:pathData="M2,4 L22,4 L22,20 L2,20 Z" />
<!-- SoC chip (centre) -->
<path
android:fillColor="#FF4CAF50"
android:pathData="M9,9 L15,9 L15,15 L9,15 Z" />
<!-- GPIO header pins (top edge) -->
<path
android:fillColor="#FF4CAF50"
android:pathData="M5,2 L5,4 M7,2 L7,4 M9,2 L9,4 M11,2 L11,4
M13,2 L13,4 M15,2 L15,4 M17,2 L17,4 M19,2 L19,4" />
<!-- USB ports (right edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF4CAF50"
android:strokeWidth="1.2"
android:pathData="M22,7 L24,7 L24,10 L22,10 M22,12 L24,12 L24,15 L22,15" />
<!-- Ethernet port (right edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF4CAF50"
android:strokeWidth="1.2"
android:pathData="M22,17 L24,17 L24,20 L22,20" />
<!-- SD card slot (bottom edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF4CAF50"
android:strokeWidth="1.2"
android:pathData="M8,20 L8,23 L14,23 L14,20" />
</vector>
@@ -0,0 +1,50 @@
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Device icon: Raspberry Pi 400
The RPi 400 is built into a keyboard chassis, so the icon uses a
keyboard/all-in-one form factor rather than a bare board outline.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Keyboard/chassis outline -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF2196F3"
android:strokeWidth="1.5"
android:pathData="M1,6 L23,6 L23,19 L1,19 Z" />
<!-- Key rows (stylised) -->
<path
android:fillColor="#FF2196F3"
android:pathData="M3,9 L5,9 L5,11 L3,11 Z
M6,9 L8,9 L8,11 L6,11 Z
M9,9 L11,9 L11,11 L9,11 Z
M12,9 L14,9 L14,11 L12,11 Z
M15,9 L17,9 L17,11 L15,11 Z
M18,9 L20,9 L20,11 L18,11 Z" />
<path
android:fillColor="#FF2196F3"
android:pathData="M3,12 L5,12 L5,14 L3,14 Z
M6,12 L8,12 L8,14 L6,14 Z
M9,12 L13,12 L13,14 L9,14 Z
M14,12 L16,12 L16,14 L14,14 Z
M17,12 L20,12 L20,14 L17,14 Z" />
<!-- Spacebar -->
<path
android:fillColor="#FF2196F3"
android:pathData="M7,15 L17,15 L17,17 L7,17 Z" />
<!-- Ports on the back (top edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF2196F3"
android:strokeWidth="1"
android:pathData="M3,4 L5,4 L5,6 M7,4 L9,4 L9,6 M11,4 L13,4 L13,6" />
</vector>
@@ -0,0 +1,66 @@
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Device icon: Raspberry Pi 5
Same board outline as RPi 4 but with a PCIe/FFC connector indicator
and the M.2 HAT+ header to visually distinguish the model.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Board outline -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFE91E63"
android:strokeWidth="1.5"
android:pathData="M2,4 L22,4 L22,20 L2,20 Z" />
<!-- SoC chip (centre, slightly larger on Pi 5) -->
<path
android:fillColor="#FFE91E63"
android:pathData="M8,8 L16,8 L16,16 L8,16 Z" />
<!-- GPIO header pins (top edge) -->
<path
android:fillColor="#FFE91E63"
android:pathData="M5,2 L5,4 M7,2 L7,4 M9,2 L9,4 M11,2 L11,4
M13,2 L13,4 M15,2 L15,4 M17,2 L17,4 M19,2 L19,4" />
<!-- USB ports (right edge) — Pi 5 has USB-A and USB-A 3.0 -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFE91E63"
android:strokeWidth="1.2"
android:pathData="M22,6 L24,6 L24,9 L22,9 M22,11 L24,11 L24,14 L22,14" />
<!-- Ethernet port (right edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFE91E63"
android:strokeWidth="1.2"
android:pathData="M22,16 L24,16 L24,20 L22,20" />
<!-- PCIe FFC connector (left edge — unique to Pi 5) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFE91E63"
android:strokeWidth="1.2"
android:pathData="M2,7 L0,7 L0,11 L2,11" />
<!-- SD card slot (bottom edge) -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFE91E63"
android:strokeWidth="1.2"
android:pathData="M8,20 L8,23 L14,23 L14,20" />
<!-- RTC battery connector indicator (small dot, Pi 5 specific) -->
<path
android:fillColor="#FFE91E63"
android:pathData="M4,18 m-1,0 a1,1 0 1,0 2,0 a1,1 0 1,0 -2,0" />
</vector>
@@ -0,0 +1,41 @@
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Generic fallback device icon — used when the device codename is not
in the supported list. Plain chip/SBC outline.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Board outline -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#FF9E9E9E"
android:strokeWidth="1.5"
android:pathData="M3,5 L21,5 L21,19 L3,19 Z" />
<!-- Chip -->
<path
android:fillColor="#FF9E9E9E"
android:pathData="M9,9 L15,9 L15,15 L9,15 Z" />
<!-- Generic pin rows -->
<path
android:fillColor="#FF9E9E9E"
android:pathData="M6,3 L6,5 M9,3 L9,5 M12,3 L12,5 M15,3 L15,5 M18,3 L18,5" />
<path
android:fillColor="#FF9E9E9E"
android:pathData="M6,19 L6,21 M9,19 L9,21 M12,19 L12,21 M15,19 L15,21 M18,19 L18,21" />
<!-- Question mark to indicate unknown -->
<path
android:fillColor="#FF9E9E9E"
android:strokeWidth="0"
android:pathData="M11.5,10.5 Q11.5,9 12,9 Q13,9 13,10 Q13,11 12,11.5 L12,12.5
M12,13.5 L12,14" />
</vector>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Device model strings for all supported PawletOS devices.
Access from any app:
Resources res = context
.createPackageContext("me.pawlet.android", 0)
.getResources();
String name = res.getString(R.string.pawlet_device_name_rpi4);
Or from XML (within the platform package itself):
@string/pawlet_device_name_rpi4
-->
<resources>
<!-- ---------------------------------------------------------------
Supported device codenames → human-readable model names
--------------------------------------------------------------- -->
<string name="pawlet_device_name_rpi4" translatable="false">Raspberry Pi 4</string>
<string name="pawlet_device_name_rpi4b" translatable="false">Raspberry Pi 4 Model B</string>
<string name="pawlet_device_name_rpi400" translatable="false">Raspberry Pi 400</string>
<string name="pawlet_device_name_rpi5" translatable="false">Raspberry Pi 5</string>
<!-- Generic fallback when device codename is not recognised -->
<string name="pawlet_device_name_unknown" translatable="false">Raspberry Pi (unknown model)</string>
<!-- ---------------------------------------------------------------
Build / release type labels
--------------------------------------------------------------- -->
<string name="pawlet_build_type_release" translatable="false">Release</string>
<string name="pawlet_build_type_nightly" translatable="false">Nightly</string>
<string name="pawlet_build_type_snapshot" translatable="false">Snapshot</string>
<string name="pawlet_build_type_experimental" translatable="false">Experimental</string>
<string name="pawlet_build_type_unofficial" translatable="false">Unofficial</string>
<!-- ---------------------------------------------------------------
Platform / OS branding
--------------------------------------------------------------- -->
<string name="pawlet_os_name" translatable="false">PawletOS</string>
<string name="pawlet_os_manufacturer" translatable="false">oxmc</string>
<!-- ---------------------------------------------------------------
Array: all supported device codenames (useful for iteration)
--------------------------------------------------------------- -->
<string-array name="pawlet_supported_devices" translatable="false">
<item>rpi4</item>
<item>rpi4b</item>
<item>rpi400</item>
<item>rpi5</item>
</string-array>
<!-- ---------------------------------------------------------------
Array: human-readable names aligned to pawlet_supported_devices
--------------------------------------------------------------- -->
<string-array name="pawlet_supported_device_names" translatable="false">
<item>Raspberry Pi 4</item>
<item>Raspberry Pi 4 Model B</item>
<item>Raspberry Pi 400</item>
<item>Raspberry Pi 5</item>
</string-array>
</resources>
+18
View File
@@ -0,0 +1,18 @@
// Copyright (C) 2025 oxmc / PawletOS
//
// SPDX-License-Identifier: Apache-2.0
android_app {
name: "PawletUpdater",
srcs: ["src/main/java/**/*.kt"],
resource_dirs: ["res"],
manifest: "AndroidManifest.xml",
// System app — needs access to UpdateEngine, PowerManager.reboot, etc.
platform_apis: true,
certificate: "platform",
privileged: true,
system_ext_specific: true,
kotlincflags: ["-Xjvm-default=all"],
}
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.pawlet.updater"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk
android:minSdkVersion="36"
android:targetSdkVersion="36" />
<!-- Permissions required for system update operations -->
<uses-permission android:name="android.permission.ACCESS_CACHE_FILESYSTEM" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REBOOT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.UPDATE_ENGINE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:icon="@android:drawable/ic_menu_upload"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<activity
android:name=".UpdatesActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".UpdaterService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdaterReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
<action android:name="me.pawlet.updater.action.CHECK_UPDATE" />
<action android:name="me.pawlet.updater.action.INSTALL_UPDATE" />
<action android:name="me.pawlet.updater.action.REBOOT_DEVICE" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- Device info card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?android:attr/selectableItemBackground"
android:padding="12dp">
<TextView
android:id="@+id/tv_current_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/current_version" />
<TextView
android:id="@+id/tv_build_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:text="@string/build_date" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?android:attr/listDivider" />
<!-- Update info area -->
<LinearLayout
android:id="@+id/layout_update_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="4dp">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/checking_for_updates" />
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_progress_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:visibility="gone" />
</LinearLayout>
<!-- Action buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/btn_check"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/check_for_updates" />
<Button
android:id="@+id/btn_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:text="@string/download" />
</LinearLayout>
</LinearLayout>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#1565C0</color>
<color name="colorPrimaryDark">#0D47A1</color>
<color name="colorAccent">#42A5F5</color>
<color name="colorSuccess">#388E3C</color>
<color name="colorError">#C62828</color>
<color name="colorWarning">#F57F17</color>
<color name="textPrimary">@android:color/primary_text_light</color>
<color name="textSecondary">@android:color/secondary_text_light</color>
</resources>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">System Update</string>
<!-- Update server — {device} is replaced with ro.product.device at runtime -->
<string name="update_server_url" translatable="false">https://updates.pawlet.me/api/v1/{device}/</string>
<!-- Download directory under getExternalCacheDir() -->
<string name="download_path" translatable="false">ota_downloads</string>
<!-- UI strings -->
<string name="checking_for_updates">Checking for updates…</string>
<string name="no_update_available">Your device is up to date.</string>
<string name="update_available">Update available: %1$s</string>
<string name="downloading">Downloading: %1$d%%</string>
<string name="verifying">Verifying package…</string>
<string name="installing">Installing: %1$d%%</string>
<string name="reboot_needed">Update installed. Reboot to apply.</string>
<string name="error_occurred">Update failed: %1$s</string>
<string name="current_version">Current version: %1$s</string>
<string name="build_date">Build date: %1$s</string>
<!-- Buttons -->
<string name="check_for_updates">Check for updates</string>
<string name="download">Download</string>
<string name="install">Install</string>
<string name="pause">Pause</string>
<string name="resume">Resume</string>
<string name="reboot">Reboot now</string>
<string name="cancel">Cancel</string>
<!-- Notification channel -->
<string name="notification_channel_id" translatable="false">pawlet_updater</string>
<string name="notification_channel_name">System Update</string>
<string name="notification_download_title">Downloading system update</string>
<string name="notification_install_title">Installing system update</string>
<string name="notification_update_available_title">System update available</string>
<string name="notification_update_available_text">Tap to view and install.</string>
<string name="notification_reboot_title">Update ready</string>
<string name="notification_reboot_text">Reboot to finish installing the update.</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.Light.DarkActionBar">
<item name="android:colorPrimary">@color/colorPrimary</item>
<item name="android:colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="android:colorAccent">@color/colorAccent</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,261 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Bridges the downloaded OTA zip to Android's update_engine via the
* UpdateEngine system API.
*
* OTA zip layout expected:
* payload.bin — raw A/B payload (STORED, not deflated)
* payload_properties.txt — key=value: FILE_HASH, FILE_SIZE,
* METADATA_HASH, METADATA_SIZE
*/
package me.pawlet.updater
import android.content.Context
import android.content.SharedPreferences
import android.os.ServiceSpecificException
import android.os.UpdateEngine
import android.os.UpdateEngineCallback
import android.util.Log
import java.io.File
import java.io.RandomAccessFile
import java.util.zip.ZipFile
class ABUpdateInstaller(
private val context: Context,
private val onStatusUpdate: (status: Int, percent: Float) -> Unit,
private val onComplete: (errorCode: Int) -> Unit,
) {
companion object {
private const val TAG = "PawletUpdater.ABInstall"
private const val PREFS_NAME = "ab_installer"
private const val PREF_INSTALLING_ID = "installing_id"
const val PREF_NEEDS_REBOOT_ID = "needs_reboot_id"
// error code 66 = kUpdateAlreadyInstalled — treat as success
private const val ERROR_UPDATE_ALREADY_INSTALLED = 66
// UpdateEngine status codes (android.os.UpdateEngine.UpdateStatusConstants)
const val STATUS_IDLE = 0
const val STATUS_CHECKING_FOR_UPDATE = 1
const val STATUS_UPDATE_AVAILABLE = 2
const val STATUS_DOWNLOADING = 3
const val STATUS_VERIFYING = 4
const val STATUS_FINALIZING = 5
const val STATUS_UPDATED_NEED_REBOOT = 6
const val STATUS_REPORTING_ERROR_EVENT = 7
// UpdateEngine error codes
const val ERROR_SUCCESS = 0
const val ERROR_GENERIC = 3
fun isInstallingUpdate(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getString(PREF_INSTALLING_ID, null) != null ||
prefs.getString(PREF_NEEDS_REBOOT_ID, null) != null
}
fun isWaitingForReboot(context: Context): Boolean =
prefs(context).getString(PREF_NEEDS_REBOOT_ID, null) != null
fun getInstallingId(context: Context): String? {
val p = prefs(context)
return p.getString(PREF_INSTALLING_ID, null)
?: p.getString(PREF_NEEDS_REBOOT_ID, null)
}
private fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val updateEngine = UpdateEngine()
private var bound = false
private val engineCallback = object : UpdateEngineCallback() {
override fun onStatusUpdate(status: Int, percent: Float) {
Log.d(TAG, "Engine status=$status progress=${(percent * 100).toInt()}%")
onStatusUpdate(status, percent)
when (status) {
STATUS_UPDATED_NEED_REBOOT -> installationDone(needsReboot = true)
STATUS_IDLE -> {
// Service restarted but we weren't actually installing
installationDone(needsReboot = false)
}
}
}
override fun onPayloadApplicationComplete(errorCode: Int) {
Log.i(TAG, "Payload application complete, errorCode=$errorCode")
if (errorCode != ERROR_SUCCESS) {
installationDone(needsReboot = false)
}
if (bound) {
updateEngine.unbind()
bound = false
}
onComplete(errorCode)
}
}
fun applyUpdate(otaZip: File) {
Log.i(TAG, "Applying update from ${otaZip.absolutePath}")
val (payloadOffset, payloadSize, headers) = parseOtaZip(otaZip)
?: run { onComplete(ERROR_GENERIC); return }
if (!bound) {
bound = updateEngine.bind(engineCallback)
if (!bound) {
Log.e(TAG, "Could not bind to update engine")
onComplete(ERROR_GENERIC)
return
}
}
prefs.edit().putString(PREF_INSTALLING_ID, otaZip.name).apply()
try {
updateEngine.applyPayload(
"file://${otaZip.absolutePath}",
payloadOffset,
payloadSize,
headers.toTypedArray(),
)
} catch (e: ServiceSpecificException) {
if (e.errorCode == ERROR_UPDATE_ALREADY_INSTALLED) {
Log.i(TAG, "Update already installed — treating as success")
installationDone(needsReboot = true)
if (bound) { updateEngine.unbind(); bound = false }
onComplete(ERROR_SUCCESS)
} else {
throw e
}
}
}
/**
* Reconnects to update_engine after the service was killed mid-install.
* We will receive status callbacks once bound again.
*/
fun reconnect() {
if (!isInstallingUpdate(context)) {
Log.w(TAG, "reconnect: not installing"); return
}
if (bound) return
Log.i(TAG, "Reconnecting to update engine")
bound = updateEngine.bind(engineCallback)
if (!bound) Log.e(TAG, "Could not rebind to update engine")
}
fun suspend() {
if (!bound) { Log.w(TAG, "suspend: not bound"); return }
Log.i(TAG, "Suspending update")
updateEngine.suspend()
}
fun resume() {
if (!bound) { Log.w(TAG, "resume: not bound"); return }
Log.i(TAG, "Resuming update")
updateEngine.resume()
}
fun cancel() {
try { updateEngine.cancel() } catch (e: Exception) { Log.w(TAG, "cancel() threw", e) }
installationDone(needsReboot = false)
if (bound) { updateEngine.unbind(); bound = false }
}
private fun installationDone(needsReboot: Boolean) {
val installingId = prefs.getString(PREF_INSTALLING_ID, null)
prefs.edit()
.remove(PREF_INSTALLING_ID)
.apply {
if (needsReboot && installingId != null)
putString(PREF_NEEDS_REBOOT_ID, installingId)
else
remove(PREF_NEEDS_REBOOT_ID)
}
.apply()
}
// ---------------------------------------------------------------------------
// ZIP parsing
// ---------------------------------------------------------------------------
private fun parseOtaZip(zip: File): Triple<Long, Long, List<String>>? {
return try {
ZipFile(zip).use { zf ->
val propsEntry = zf.getEntry("payload_properties.txt")
?: run { Log.e(TAG, "payload_properties.txt not found"); return null }
val payloadEntry = zf.getEntry("payload.bin")
?: run { Log.e(TAG, "payload.bin not found"); return null }
if (payloadEntry.method != java.util.zip.ZipEntry.STORED) {
Log.e(TAG, "payload.bin must be STORED (not compressed)"); return null
}
val headers = zf.getInputStream(propsEntry).bufferedReader()
.readLines().filter { it.contains('=') }
val dataOffset = getEntryDataOffset(zip, payloadEntry.name)
?: run { Log.e(TAG, "Could not locate payload.bin data offset"); return null }
Triple(dataOffset, payloadEntry.size, headers)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing OTA zip", e); null
}
}
/**
* Scans ZIP local file headers from the start to find [entryName]'s data offset.
*
* Local file header (little-endian):
* offset 0: signature (4) = 0x04034b50
* offset 26: filename length (2)
* offset 28: extra field length (2)
* offset 30: filename (n bytes)
* offset 30+n: extra field (m bytes)
* offset 30+n+m: DATA STARTS HERE
*/
private fun getEntryDataOffset(zipFile: File, entryName: String): Long? {
return try {
RandomAccessFile(zipFile, "r").use { raf ->
val size = raf.length()
var offset = 0L
while (offset + 30 <= size) {
raf.seek(offset)
if (readUint32(raf) != 0x04034b50L) break
raf.seek(offset + 26)
val nameLen = readUint16(raf).toLong()
val extraLen = readUint16(raf).toLong()
val dataStart = offset + 30L + nameLen + extraLen
raf.seek(offset + 30L)
val name = String(ByteArray(nameLen.toInt()).also { raf.readFully(it) })
if (name == entryName) return dataStart
raf.seek(offset + 18L)
offset = dataStart + readUint32(raf)
}
null
}
} catch (e: Exception) {
Log.e(TAG, "Error finding entry data offset", e); null
}
}
private fun readUint16(raf: RandomAccessFile): Int {
val b0 = raf.read(); val b1 = raf.read()
return (b1 shl 8) or b0
}
private fun readUint32(raf: RandomAccessFile): Long {
val b0 = raf.read().toLong(); val b1 = raf.read().toLong()
val b2 = raf.read().toLong(); val b3 = raf.read().toLong()
return (b3 shl 24) or (b2 shl 16) or (b1 shl 8) or b0
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* HTTP download with resume support, progress callbacks, and cancellation.
*/
package me.pawlet.updater
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
class DownloadClient(
private val url: String,
private val destFile: File,
private val expectedSize: Long,
private val onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit,
private val onComplete: (file: File) -> Unit,
private val onError: (message: String) -> Unit,
) {
companion object {
private const val TAG = "PawletUpdater.Download"
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 30_000
private const val BUF_SIZE = 64 * 1024 // 64 KiB
}
@Volatile private var cancelled = false
/** Call from any thread to abort an in-progress download. */
fun cancel() { cancelled = true }
/**
* Starts the download synchronously on the calling thread.
* Call from a worker thread or coroutine.
*/
fun run() {
cancelled = false
try {
doDownload()
} catch (e: Exception) {
if (!cancelled) {
Log.e(TAG, "Download error", e)
onError(e.message ?: "Unknown error")
}
}
}
private fun doDownload() {
// Support resume: if the file already exists, request only the remaining bytes.
val existingBytes = if (destFile.exists()) destFile.length() else 0L
if (existingBytes > 0 && existingBytes == expectedSize) {
Log.d(TAG, "File already fully downloaded: ${destFile.name}")
onComplete(destFile)
return
}
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
if (existingBytes > 0) {
setRequestProperty("Range", "bytes=$existingBytes-")
}
}
conn.connect()
val status = conn.responseCode
val canResume = status == HttpURLConnection.HTTP_PARTIAL && existingBytes > 0
if (status != HttpURLConnection.HTTP_OK && !canResume) {
conn.disconnect()
onError("Unexpected HTTP status: $status")
return
}
val contentLength = conn.contentLengthLong
val totalBytes = if (canResume) existingBytes + contentLength else contentLength
var writtenSoFar = if (canResume) existingBytes else 0L
Log.d(TAG, "Downloading ${destFile.name} — resume=$canResume " +
"existing=$existingBytes total=$totalBytes")
val outputStream = FileOutputStream(destFile, canResume /* append */)
val buf = ByteArray(BUF_SIZE)
try {
conn.inputStream.use { input ->
var n: Int
while (!cancelled && input.read(buf).also { n = it } != -1) {
outputStream.write(buf, 0, n)
writtenSoFar += n
onProgress(writtenSoFar, totalBytes)
}
}
} finally {
outputStream.flush()
outputStream.close()
conn.disconnect()
}
if (cancelled) {
Log.d(TAG, "Download cancelled")
return
}
Log.d(TAG, "Download complete: ${destFile.name} ($writtenSoFar bytes)")
onComplete(destFile)
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Utilities for querying the PawletOS update server and comparing versions.
*/
package me.pawlet.updater
import android.content.Context
import android.os.Build
import android.os.SystemProperties
import android.util.Log
import me.pawlet.updater.model.UpdateEntry
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object OTAUtils {
private const val TAG = "PawletUpdater"
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 30_000
// ---------------------------------------------------------------------------
// Device info
// ---------------------------------------------------------------------------
/** ro.product.device, e.g. "rpi4" or "rpi5". */
val deviceName: String
get() = Build.DEVICE
/** ro.build.version.incremental — used for version comparison. */
val buildVersion: String
get() = SystemProperties.get("ro.build.version.incremental", "0")
/** ro.build.date.utc as a Long. */
val buildDateUtc: Long
get() = SystemProperties.get("ro.build.date.utc", "0").toLongOrNull() ?: 0L
fun formattedBuildDate(): String {
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return sdf.format(Date(buildDateUtc * 1000L))
}
// ---------------------------------------------------------------------------
// Server query
// ---------------------------------------------------------------------------
/**
* Fetches the update manifest for this device.
* Returns an ordered list (newest first) of available updates, or null on error.
*
* Server URL format: https://updates.pawlet.me/api/v1/{device}/
*/
fun fetchUpdates(context: Context): List<UpdateEntry>? {
val baseUrl = context.getString(R.string.update_server_url)
.replace("{device}", deviceName)
Log.d(TAG, "Fetching updates from $baseUrl")
val conn = try {
(URL(baseUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("User-Agent", "PawletUpdater/$buildVersion ($deviceName)")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to open connection", e)
return null
}
return try {
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
Log.w(TAG, "Server returned HTTP ${conn.responseCode}")
return null
}
val json = BufferedReader(InputStreamReader(conn.inputStream)).use { it.readText() }
parseUpdateJson(json)
} catch (e: Exception) {
Log.e(TAG, "Error fetching updates", e)
null
} finally {
conn.disconnect()
}
}
/** Parses the JSON response and returns a list of entries sorted newest-first. */
fun parseUpdateJson(json: String): List<UpdateEntry> {
val root = JSONObject(json)
val response = root.getJSONArray("response")
val entries = (0 until response.length()).map { UpdateEntry.fromJson(response.getJSONObject(it)) }
return entries.sortedByDescending { it.datetime }
}
/**
* Returns the first entry that is newer than the current build, or null if
* the device is up to date.
*/
fun findNewerUpdate(entries: List<UpdateEntry>): UpdateEntry? =
entries.firstOrNull { it.datetime > buildDateUtc }
// ---------------------------------------------------------------------------
// File verification
// ---------------------------------------------------------------------------
/** Verifies the SHA-256 hash of a local file against the expected hex string. */
fun verifySha256(file: File, expectedHex: String): Boolean {
if (expectedHex.isBlank()) {
Log.w(TAG, "No expected SHA-256 provided — skipping verification")
return true
}
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { fis ->
val buf = ByteArray(8 * 1024)
var n: Int
while (fis.read(buf).also { n = it } != -1) {
digest.update(buf, 0, n)
}
}
val actual = digest.digest().joinToString("") { "%02x".format(it) }
val match = actual.equals(expectedHex, ignoreCase = true)
if (!match) Log.e(TAG, "SHA-256 mismatch: expected=$expectedHex actual=$actual")
return match
}
// ---------------------------------------------------------------------------
// Download directory
// ---------------------------------------------------------------------------
fun getDownloadDir(context: Context): File {
val base = context.externalCacheDir ?: context.cacheDir
val dir = File(base, context.getString(R.string.download_path))
dir.mkdirs()
return dir
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Broadcast receiver that triggers update checks and handles reboot requests.
*/
package me.pawlet.updater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.PowerManager
import android.util.Log
class UpdaterReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "PawletUpdater.Receiver"
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED -> {
Log.d(TAG, "Boot complete — scheduling update check")
if (isNetworkAvailable(context)) {
startService(context, UpdaterService.ACTION_CHECK)
}
}
ConnectivityManager.CONNECTIVITY_ACTION -> {
// Only trigger a check on the first network-up event after boot.
if (isNetworkAvailable(context)) {
Log.d(TAG, "Network available — triggering update check")
startService(context, UpdaterService.ACTION_CHECK)
}
}
UpdaterService.ACTION_CHECK -> {
startService(context, UpdaterService.ACTION_CHECK)
}
UpdaterService.ACTION_INSTALL -> {
startService(context, UpdaterService.ACTION_INSTALL)
}
UpdaterService.ACTION_REBOOT -> {
Log.i(TAG, "Rebooting to apply OTA update")
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
pm.reboot("tryboot")
}
}
}
private fun startService(context: Context, action: String) {
val intent = Intent(action, null, context, UpdaterService::class.java)
context.startForegroundService(intent)
}
private fun isNetworkAvailable(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
@@ -0,0 +1,178 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Foreground service that drives update downloads and installations.
* Started by UpdaterReceiver (on boot, network change, or manual check).
* Binds UpdatesActivity to the controller while the UI is visible.
*/
package me.pawlet.updater
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import me.pawlet.updater.controller.UpdaterController
import me.pawlet.updater.model.Update
import me.pawlet.updater.model.UpdateStatus
class UpdaterService : Service(), UpdaterController.Listener {
companion object {
private const val TAG = "PawletUpdater.Service"
private const val NOTIF_ID_PROGRESS = 1001
private const val NOTIF_ID_RESULT = 1002
const val ACTION_CHECK = "me.pawlet.updater.action.CHECK_UPDATE"
const val ACTION_INSTALL = "me.pawlet.updater.action.INSTALL_UPDATE"
const val ACTION_REBOOT = "me.pawlet.updater.action.REBOOT_DEVICE"
}
inner class LocalBinder : Binder() {
fun getService() = this@UpdaterService
}
private val binder = LocalBinder()
val controller = UpdaterController(this)
private lateinit var nm: NotificationManager
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
override fun onCreate() {
super.onCreate()
nm = getSystemService(NotificationManager::class.java)
createNotificationChannel()
controller.listener = this
// Re-attach to update_engine if we were killed mid-install
controller.reconnectIfInstalling()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CHECK -> startForegroundAndCheck()
ACTION_INSTALL -> controller.startInstall()
ACTION_REBOOT -> controller.rebootDevice()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder = binder
override fun onDestroy() {
super.onDestroy()
controller.listener = null
}
// ---------------------------------------------------------------------------
// UpdaterController.Listener
// ---------------------------------------------------------------------------
override fun onUpdateStateChanged(update: Update?) {
Log.d(TAG, "State: ${update?.status} progress=${update?.progress}")
when (update?.status) {
UpdateStatus.CHECKING -> updateNotification("Checking for updates…", -1)
UpdateStatus.DOWNLOADING -> updateNotification(
getString(R.string.notification_download_title), update.progress)
UpdateStatus.VERIFYING -> updateNotification("Verifying…", -1)
UpdateStatus.INSTALLING -> updateNotification(
getString(R.string.notification_install_title), update.progress)
UpdateStatus.REBOOT_NEEDED -> {
showResultNotification(
getString(R.string.notification_reboot_title),
getString(R.string.notification_reboot_text),
buildRebootIntent(),
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
UpdateStatus.AVAILABLE -> {
showResultNotification(
getString(R.string.notification_update_available_title),
getString(R.string.notification_update_available_text),
buildMainIntent(),
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
UpdateStatus.NO_UPDATE, null -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
UpdateStatus.ERROR -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
else -> {}
}
}
override fun onStatusMessage(message: String) {
Log.d(TAG, message)
}
// ---------------------------------------------------------------------------
// Notification helpers
// ---------------------------------------------------------------------------
private fun startForegroundAndCheck() {
val notif = buildProgressNotification("Checking for updates…", -1)
startForeground(NOTIF_ID_PROGRESS, notif)
controller.checkForUpdates()
}
private fun updateNotification(title: String, progress: Int) {
nm.notify(NOTIF_ID_PROGRESS, buildProgressNotification(title, progress))
}
private fun buildProgressNotification(title: String, progress: Int): Notification {
val builder = Notification.Builder(this, getString(R.string.notification_channel_id))
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setContentTitle(title)
.setOngoing(true)
.setContentIntent(buildMainIntent())
if (progress >= 0) builder.setProgress(100, progress, false)
else builder.setProgress(0, 0, true) // indeterminate
return builder.build()
}
private fun showResultNotification(title: String, text: String, action: PendingIntent) {
val notif = Notification.Builder(this, getString(R.string.notification_channel_id))
.setSmallIcon(android.R.drawable.ic_menu_upload)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(action)
.build()
nm.notify(NOTIF_ID_RESULT, notif)
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
getString(R.string.notification_channel_id),
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
}
private fun buildMainIntent(): PendingIntent {
val intent = Intent(this, UpdatesActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
return PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun buildRebootIntent(): PendingIntent {
val intent = Intent(ACTION_REBOOT).setClass(this, UpdaterReceiver::class.java)
return PendingIntent.getBroadcast(this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
}
@@ -0,0 +1,226 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Main UI — shows device version, update availability, and progress.
* Binds to UpdaterService while visible.
*/
package me.pawlet.updater
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import me.pawlet.updater.controller.UpdaterController
import me.pawlet.updater.model.Update
import me.pawlet.updater.model.UpdateStatus
class UpdatesActivity : Activity(), UpdaterController.Listener {
private lateinit var tvCurrentVersion: TextView
private lateinit var tvBuildDate: TextView
private lateinit var tvStatus: TextView
private lateinit var progressBar: ProgressBar
private lateinit var tvProgressDetail: TextView
private lateinit var btnCheck: Button
private lateinit var btnAction: Button
private var service: UpdaterService? = null
private var currentUpdate: Update? = null
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, ib: IBinder) {
service = (ib as UpdaterService.LocalBinder).getService()
service?.controller?.listener = this@UpdatesActivity
// Refresh UI with latest known state.
onUpdateStateChanged(service?.controller?.let {
currentUpdate
})
}
override fun onServiceDisconnected(name: ComponentName) {
service = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_updates)
tvCurrentVersion = findViewById(R.id.tv_current_version)
tvBuildDate = findViewById(R.id.tv_build_date)
tvStatus = findViewById(R.id.tv_status)
progressBar = findViewById(R.id.progress_bar)
tvProgressDetail = findViewById(R.id.tv_progress_detail)
btnCheck = findViewById(R.id.btn_check)
btnAction = findViewById(R.id.btn_action)
tvCurrentVersion.text = getString(R.string.current_version, OTAUtils.buildVersion)
tvBuildDate.text = getString(R.string.build_date, OTAUtils.formattedBuildDate())
btnCheck.setOnClickListener {
startService(Intent(UpdaterService.ACTION_CHECK, null, this, UpdaterService::class.java))
bindUpdaterService()
tvStatus.text = getString(R.string.checking_for_updates)
}
btnAction.setOnClickListener { handleActionButton() }
}
override fun onStart() {
super.onStart()
bindUpdaterService()
}
override fun onStop() {
super.onStop()
service?.controller?.listener = null
try { unbindService(serviceConnection) } catch (_: Exception) {}
service = null
}
// ---------------------------------------------------------------------------
// UpdaterController.Listener
// ---------------------------------------------------------------------------
override fun onUpdateStateChanged(update: Update?) {
runOnUiThread {
currentUpdate = update
applyUiState(update)
}
}
override fun onStatusMessage(message: String) {
runOnUiThread { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() }
}
// ---------------------------------------------------------------------------
// UI state machine
// ---------------------------------------------------------------------------
private fun applyUiState(update: Update?) {
when (update?.status) {
null, UpdateStatus.UNKNOWN -> {
tvStatus.text = ""
hideProgress()
btnAction.visibility = View.GONE
}
UpdateStatus.CHECKING -> {
tvStatus.text = getString(R.string.checking_for_updates)
showIndeterminateProgress()
btnAction.visibility = View.GONE
}
UpdateStatus.NO_UPDATE -> {
tvStatus.text = getString(R.string.no_update_available)
hideProgress()
btnAction.visibility = View.GONE
}
UpdateStatus.AVAILABLE -> {
tvStatus.text = getString(R.string.update_available, update.entry.version)
hideProgress()
btnAction.visibility = View.VISIBLE
btnAction.text = when {
update.downloadedFile?.exists() == true -> getString(R.string.install)
else -> getString(R.string.download)
}
}
UpdateStatus.DOWNLOADING -> {
tvStatus.text = getString(R.string.downloading, update.progress)
showProgress(update.progress)
btnAction.visibility = View.VISIBLE
btnAction.text = getString(R.string.pause)
}
UpdateStatus.PAUSED -> {
tvStatus.text = getString(R.string.downloading, update.progress) + " (paused)"
showProgress(update.progress)
btnAction.visibility = View.VISIBLE
btnAction.text = getString(R.string.resume)
}
UpdateStatus.VERIFYING -> {
tvStatus.text = getString(R.string.verifying)
showIndeterminateProgress()
btnAction.visibility = View.GONE
}
UpdateStatus.INSTALLING -> {
tvStatus.text = getString(R.string.installing, update.progress)
showProgress(update.progress)
btnAction.visibility = View.GONE
}
UpdateStatus.REBOOT_NEEDED -> {
tvStatus.text = getString(R.string.reboot_needed)
hideProgress()
btnAction.visibility = View.VISIBLE
btnAction.text = getString(R.string.reboot)
}
UpdateStatus.ERROR -> {
tvStatus.text = getString(R.string.error_occurred, update.errorMessage ?: "unknown")
hideProgress()
btnAction.visibility = View.GONE
}
UpdateStatus.INSTALLED -> {
tvStatus.text = getString(R.string.reboot_needed)
hideProgress()
btnAction.visibility = View.VISIBLE
btnAction.text = getString(R.string.reboot)
}
}
}
private fun handleActionButton() {
val update = currentUpdate ?: return
when (update.status) {
UpdateStatus.AVAILABLE -> {
if (update.downloadedFile?.exists() == true) {
startService(Intent(UpdaterService.ACTION_INSTALL, null, this, UpdaterService::class.java))
} else {
startForegroundService(Intent(UpdaterService.ACTION_CHECK, null, this, UpdaterService::class.java))
service?.controller?.startDownload()
}
}
UpdateStatus.DOWNLOADING -> service?.controller?.pauseDownload()
UpdateStatus.PAUSED -> service?.controller?.resumeDownload()
UpdateStatus.REBOOT_NEEDED, UpdateStatus.INSTALLED ->
startService(Intent(UpdaterService.ACTION_REBOOT, null, this, UpdaterService::class.java))
else -> {}
}
}
// ---------------------------------------------------------------------------
// Progress helpers
// ---------------------------------------------------------------------------
private fun showProgress(pct: Int) {
progressBar.visibility = View.VISIBLE
progressBar.isIndeterminate = false
progressBar.progress = pct
tvProgressDetail.visibility = View.VISIBLE
tvProgressDetail.text = "$pct%"
}
private fun showIndeterminateProgress() {
progressBar.visibility = View.VISIBLE
progressBar.isIndeterminate = true
tvProgressDetail.visibility = View.GONE
}
private fun hideProgress() {
progressBar.visibility = View.GONE
tvProgressDetail.visibility = View.GONE
}
private fun bindUpdaterService() {
bindService(
Intent(this, UpdaterService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE,
)
}
}
@@ -0,0 +1,274 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Orchestrates the full update lifecycle:
* CHECK → DOWNLOAD → VERIFY → INSTALL → REBOOT
*
* All network/disk operations run on a dedicated background thread.
* UI and service observe state via the [listener] callback.
*
* Persistent state:
* - ABUpdateInstaller uses SharedPreferences to survive service restarts.
* - UpdatesDbHelper writes every status transition to SQLite for history.
*/
package me.pawlet.updater.controller
import android.content.Context
import android.os.PowerManager
import android.util.Log
import me.pawlet.updater.ABUpdateInstaller
import me.pawlet.updater.DownloadClient
import me.pawlet.updater.OTAUtils
import me.pawlet.updater.db.UpdatesDbHelper
import me.pawlet.updater.model.Update
import me.pawlet.updater.model.UpdateEntry
import me.pawlet.updater.model.UpdateStatus
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.Future
class UpdaterController(private val context: Context) {
companion object {
private const val TAG = "PawletUpdater.Ctrl"
}
interface Listener {
fun onUpdateStateChanged(update: Update?)
fun onStatusMessage(message: String)
}
var listener: Listener? = null
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "updater-worker").apply { isDaemon = true }
}
@Volatile private var currentUpdate: Update? = null
@Volatile private var currentFuture: Future<*>? = null
private var downloadClient: DownloadClient? = null
private var abInstaller: ABUpdateInstaller? = null
private val db = UpdatesDbHelper(context)
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
fun checkForUpdates() {
submitTask {
notify(null, UpdateStatus.CHECKING)
Log.i(TAG, "Checking for updates (device=${OTAUtils.deviceName} " +
"build=${OTAUtils.buildVersion})")
val entries = OTAUtils.fetchUpdates(context)
if (entries == null) {
notifyError("Could not reach update server. Check your network.")
return@submitTask
}
val entry = OTAUtils.findNewerUpdate(entries)
if (entry == null) {
Log.i(TAG, "Device is up to date")
val noUpdate = Update(
UpdateEntry(0, "", "", "", "", 0L, "", ""),
UpdateStatus.NO_UPDATE,
)
listener?.onUpdateStateChanged(noUpdate)
return@submitTask
}
Log.i(TAG, "Update found: ${entry.filename} v${entry.version}")
db.upsert(entry, UpdatesDbHelper.STATUS_AVAILABLE)
val update = Update(entry, UpdateStatus.AVAILABLE)
currentUpdate = update
listener?.onUpdateStateChanged(update)
}
}
fun startDownload() {
val entry = currentUpdate?.entry ?: return
val destFile = File(OTAUtils.getDownloadDir(context), entry.filename)
submitTask {
updateStatus(UpdateStatus.DOWNLOADING)
db.upsert(entry, UpdatesDbHelper.STATUS_DOWNLOADING)
downloadClient = DownloadClient(
url = entry.url,
destFile = destFile,
expectedSize = entry.size,
onProgress = { downloaded, total ->
val pct = if (total > 0) ((downloaded * 100) / total).toInt() else 0
currentUpdate?.progress = pct
listener?.onUpdateStateChanged(currentUpdate)
},
onComplete = { file ->
currentUpdate?.downloadedFile = file
db.upsert(entry, UpdatesDbHelper.STATUS_VERIFYING, file.absolutePath)
verifyAndPrepare(file, entry)
},
onError = { msg -> notifyError(msg) },
)
downloadClient?.run()
}
}
fun pauseDownload() {
downloadClient?.cancel()
updateStatus(UpdateStatus.PAUSED)
}
fun resumeDownload() = startDownload()
fun startInstall() {
val update = currentUpdate ?: return
val file = update.downloadedFile ?: run {
notifyError("No downloaded file to install")
return
}
val entry = update.entry
submitTask {
db.upsert(entry, UpdatesDbHelper.STATUS_INSTALLING, file.absolutePath)
abInstaller = ABUpdateInstaller(
context = context,
onStatusUpdate = { status, percent ->
val pct = (percent * 100).toInt()
currentUpdate?.progress = pct
when (status) {
ABUpdateInstaller.STATUS_DOWNLOADING,
ABUpdateInstaller.STATUS_FINALIZING -> updateStatus(UpdateStatus.INSTALLING)
ABUpdateInstaller.STATUS_VERIFYING -> updateStatus(UpdateStatus.VERIFYING)
}
listener?.onUpdateStateChanged(currentUpdate)
},
onComplete = { errorCode ->
if (errorCode == ABUpdateInstaller.ERROR_SUCCESS) {
Log.i(TAG, "Update installed successfully — reboot pending")
db.updateStatus(entry.id.ifBlank { entry.sha256 }, UpdatesDbHelper.STATUS_INSTALLED)
updateStatus(UpdateStatus.REBOOT_NEEDED)
} else {
db.updateStatus(entry.id.ifBlank { entry.sha256 }, UpdatesDbHelper.STATUS_ERROR)
notifyError("Installation failed (errorCode=$errorCode)")
}
},
)
updateStatus(UpdateStatus.INSTALLING)
abInstaller?.applyUpdate(file)
}
}
fun cancelInstall() {
abInstaller?.cancel()
currentUpdate?.entry?.let { entry ->
db.updateStatus(entry.id.ifBlank { entry.sha256 }, UpdatesDbHelper.STATUS_ERROR)
}
updateStatus(UpdateStatus.ERROR)
}
fun suspendInstall() {
abInstaller?.suspend()
Log.i(TAG, "Install suspended")
}
fun resumeInstall() {
abInstaller?.resume()
Log.i(TAG, "Install resumed")
}
/**
* Called on service start to re-attach to update_engine if we were killed mid-install.
* ABUpdateInstaller persists the installing ID in SharedPreferences so we can detect this.
*/
fun reconnectIfInstalling() {
if (!ABUpdateInstaller.isInstallingUpdate(context)) return
Log.i(TAG, "Reconnecting to in-progress installation")
if (abInstaller == null) {
abInstaller = ABUpdateInstaller(
context = context,
onStatusUpdate = { status, percent ->
val pct = (percent * 100).toInt()
currentUpdate?.progress = pct
when (status) {
ABUpdateInstaller.STATUS_DOWNLOADING,
ABUpdateInstaller.STATUS_FINALIZING -> updateStatus(UpdateStatus.INSTALLING)
ABUpdateInstaller.STATUS_VERIFYING -> updateStatus(UpdateStatus.VERIFYING)
ABUpdateInstaller.STATUS_UPDATED_NEED_REBOOT -> updateStatus(UpdateStatus.REBOOT_NEEDED)
}
listener?.onUpdateStateChanged(currentUpdate)
},
onComplete = { errorCode ->
if (errorCode == ABUpdateInstaller.ERROR_SUCCESS) {
Log.i(TAG, "Reconnected install completed — reboot pending")
updateStatus(UpdateStatus.REBOOT_NEEDED)
} else {
notifyError("Installation failed after reconnect (errorCode=$errorCode)")
}
},
)
}
abInstaller?.reconnect()
updateStatus(UpdateStatus.INSTALLING)
}
/**
* Triggers a "tryboot" reboot to activate slot B.
*
* The reboot reason is read from ro.pawlet.ota.reboot_reason (set in
* vendor.prop to "reboot,tryboot" on RPi devices). This causes the RPi
* firmware to boot from partition 2 (boot_b) for one attempt.
*/
fun rebootDevice() {
Log.i(TAG, "Initiating OTA reboot")
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
// The reason string is forwarded to the kernel reboot() syscall.
// On RPi the firmware interprets "0 tryboot" / "tryboot" to load boot_b.
pm.reboot("tryboot")
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
private fun verifyAndPrepare(file: File, entry: UpdateEntry) {
updateStatus(UpdateStatus.VERIFYING)
Log.d(TAG, "Verifying ${file.name}")
if (!OTAUtils.verifySha256(file, entry.sha256)) {
file.delete()
db.updateStatus(entry.id.ifBlank { entry.sha256 }, UpdatesDbHelper.STATUS_ERROR)
notifyError("Package verification failed — file may be corrupt or tampered")
return
}
Log.i(TAG, "Verification OK — ready to install")
db.upsert(entry, UpdatesDbHelper.STATUS_AVAILABLE, file.absolutePath)
updateStatus(UpdateStatus.AVAILABLE) // Let UI show Install button
listener?.onUpdateStateChanged(currentUpdate)
}
private fun updateStatus(status: UpdateStatus) {
currentUpdate?.status = status
listener?.onUpdateStateChanged(currentUpdate)
}
private fun notify(update: Update?, status: UpdateStatus) {
currentUpdate = update?.also { it.status = status }
listener?.onUpdateStateChanged(currentUpdate)
}
private fun notifyError(msg: String) {
Log.e(TAG, "Error: $msg")
currentUpdate?.status = UpdateStatus.ERROR
currentUpdate?.errorMessage = msg
listener?.onUpdateStateChanged(currentUpdate)
}
private fun submitTask(block: () -> Unit): Future<*> =
executor.submit(block).also { currentFuture = it }
}
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*/
package me.pawlet.updater.db
import android.database.Cursor
/** A plain data class representing one row from the updates table. */
data class DbUpdateRow(
val id: String,
val filename: String,
val version: String,
val romType: String,
val size: Long,
val datetime: Long,
val url: String,
val sha256: String,
val status: String,
val filePath: String?,
val installedAt: Long?,
) {
companion object {
fun fromCursor(c: Cursor) = DbUpdateRow(
id = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_ID)),
filename = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_FILENAME)),
version = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_VERSION)),
romType = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_ROMTYPE)),
size = c.getLong(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_SIZE)),
datetime = c.getLong(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_DATETIME)),
url = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_URL)),
sha256 = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_SHA256)),
status = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_STATUS)),
filePath = c.getString(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_FILE_PATH)),
installedAt = c.getLong(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_INSTALLED_AT))
.takeIf { !c.isNull(c.getColumnIndexOrThrow(UpdatesDbHelper.COL_INSTALLED_AT)) },
)
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* SQLite helper for persistent update history.
*
* Schema (table "updates"):
* id TEXT PRIMARY KEY — sha256 / server-assigned id
* filename TEXT NOT NULL
* version TEXT NOT NULL
* romtype TEXT NOT NULL
* size INTEGER NOT NULL
* datetime INTEGER NOT NULL — unix timestamp from server
* url TEXT NOT NULL
* sha256 TEXT NOT NULL
* status TEXT NOT NULL — "available","downloading","verifying",
* "installing","installed","error"
* file_path TEXT — absolute path once downloaded
* installed_at INTEGER — unix timestamp set on successful install
*/
package me.pawlet.updater.db
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import me.pawlet.updater.model.UpdateEntry
class UpdatesDbHelper(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
companion object {
private const val DB_NAME = "pawlet_updates.db"
private const val DB_VERSION = 1
const val TABLE = "updates"
const val COL_ID = "id"
const val COL_FILENAME = "filename"
const val COL_VERSION = "version"
const val COL_ROMTYPE = "romtype"
const val COL_SIZE = "size"
const val COL_DATETIME = "datetime"
const val COL_URL = "url"
const val COL_SHA256 = "sha256"
const val COL_STATUS = "status"
const val COL_FILE_PATH = "file_path"
const val COL_INSTALLED_AT = "installed_at"
// Status constants stored in DB
const val STATUS_AVAILABLE = "available"
const val STATUS_DOWNLOADING = "downloading"
const val STATUS_VERIFYING = "verifying"
const val STATUS_INSTALLING = "installing"
const val STATUS_INSTALLED = "installed"
const val STATUS_ERROR = "error"
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE $TABLE (
$COL_ID TEXT PRIMARY KEY,
$COL_FILENAME TEXT NOT NULL,
$COL_VERSION TEXT NOT NULL,
$COL_ROMTYPE TEXT NOT NULL,
$COL_SIZE INTEGER NOT NULL,
$COL_DATETIME INTEGER NOT NULL,
$COL_URL TEXT NOT NULL,
$COL_SHA256 TEXT NOT NULL,
$COL_STATUS TEXT NOT NULL DEFAULT '$STATUS_AVAILABLE',
$COL_FILE_PATH TEXT,
$COL_INSTALLED_AT INTEGER
)
""".trimIndent())
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE")
onCreate(db)
}
// ---------------------------------------------------------------------------
// Write helpers
// ---------------------------------------------------------------------------
/** Insert or replace the full entry with [status]. */
fun upsert(entry: UpdateEntry, status: String, filePath: String? = null) {
val cv = ContentValues().apply {
put(COL_ID, entry.id.ifBlank { entry.sha256 })
put(COL_FILENAME, entry.filename)
put(COL_VERSION, entry.version)
put(COL_ROMTYPE, entry.romType)
put(COL_SIZE, entry.size)
put(COL_DATETIME, entry.datetime)
put(COL_URL, entry.url)
put(COL_SHA256, entry.sha256)
put(COL_STATUS, status)
if (filePath != null) put(COL_FILE_PATH, filePath)
}
writableDatabase.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
}
/** Update just the status (and optionally file_path / installed_at) for [id]. */
fun updateStatus(id: String, status: String, filePath: String? = null) {
val cv = ContentValues().apply {
put(COL_STATUS, status)
if (filePath != null) put(COL_FILE_PATH, filePath)
if (status == STATUS_INSTALLED) put(COL_INSTALLED_AT, System.currentTimeMillis() / 1000)
}
writableDatabase.update(TABLE, cv, "$COL_ID = ?", arrayOf(id))
}
// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------
/** Returns all rows ordered newest first. */
fun getAllUpdates(): List<DbUpdateRow> {
val rows = mutableListOf<DbUpdateRow>()
readableDatabase.query(
TABLE, null, null, null, null, null, "$COL_DATETIME DESC"
).use { c ->
while (c.moveToNext()) rows += DbUpdateRow.fromCursor(c)
}
return rows
}
/** Returns the most-recently installed update, or null. */
fun getLastInstalled(): DbUpdateRow? =
readableDatabase.query(
TABLE, null,
"$COL_STATUS = ?", arrayOf(STATUS_INSTALLED),
null, null, "$COL_INSTALLED_AT DESC", "1"
).use { c -> if (c.moveToFirst()) DbUpdateRow.fromCursor(c) else null }
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Runtime state of an update — wraps an UpdateEntry and adds local tracking.
*/
package me.pawlet.updater.model
import java.io.File
enum class UpdateStatus {
UNKNOWN,
CHECKING,
AVAILABLE,
DOWNLOADING,
PAUSED,
VERIFYING,
INSTALLING,
INSTALLED,
REBOOT_NEEDED,
NO_UPDATE,
ERROR,
}
data class Update(
val entry: UpdateEntry,
var status: UpdateStatus = UpdateStatus.AVAILABLE,
var progress: Int = 0,
var downloadedFile: File? = null,
var errorMessage: String? = null,
)
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2017 The LineageOS Project
* Copyright (C) 2025 oxmc / PawletOS
* SPDX-License-Identifier: Apache-2.0
*
* Represents a single update entry returned by the PawletOS update server.
*
* Expected JSON shape (LineageOS-compatible API):
* {
* "response": [{
* "datetime": 1234567890,
* "filename": "PawletOS-20250101-rpi4.zip",
* "id": "<sha256 of the zip>",
* "romtype": "release",
* "sha256": "<sha256 of the zip>",
* "size": 1073741824,
* "url": "https://updates.pawlet.me/builds/rpi4/PawletOS-20250101-rpi4.zip",
* "version": "1.0.0"
* }]
* }
*/
package me.pawlet.updater.model
import org.json.JSONObject
data class UpdateEntry(
val datetime: Long,
val filename: String,
val id: String,
val romType: String,
val sha256: String,
val size: Long,
val url: String,
val version: String,
) {
companion object {
fun fromJson(json: JSONObject) = UpdateEntry(
datetime = json.getLong("datetime"),
filename = json.getString("filename"),
id = json.optString("id", ""),
romType = json.optString("romtype", "release"),
sha256 = json.optString("sha256", json.optString("id", "")),
size = json.getLong("size"),
url = json.getString("url"),
version = json.getString("version"),
)
}
}
+8
View File
@@ -0,0 +1,8 @@
#
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
LOCAL_PATH := $(call my-dir)
include $(call all-makefiles-under,$(LOCAL_PATH))
+8
View File
@@ -0,0 +1,8 @@
#
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
LOCAL_PATH := $(call my-dir)
include $(call all-makefiles-under,$(LOCAL_PATH))
+10
View File
@@ -0,0 +1,10 @@
#
# Copyright (C) 2025 oxmc / PawletOS
# SPDX-License-Identifier: Apache-2.0
#
# Vendor build config — included by AOSP's build/make/core/config.mk
# via: -include $(TOPDIR)vendor/*/build/core/config.mk
#
# Include PawletOS build tasks (bacon, etc.)
include $(TOPDIR)vendor/pawlet/build/tasks/bacon.mk
+7
View File
@@ -0,0 +1,7 @@
#
# Copyright (C) 2025 oxmc / PawletOS
# SPDX-License-Identifier: Apache-2.0
#
# Intentionally empty — bacon is defined in build/tasks/bacon.mk and loaded
# by build/core/config.mk. Nothing extra goes here.
#
+227
View File
@@ -0,0 +1,227 @@
#!/bin/bash
#
# Copyright (C) 2012 The CyanogenMod Project
# Copyright (C) 2017 The LineageOS Project
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
# PawletOS build environment helpers.
# Adapted from vendor/lineage/build/envsetup.sh (The LineageOS Project).
# Source after AOSP's envsetup.sh:
# source build/envsetup.sh
# source vendor/pawlet/build/envsetup.sh
#
# ---------------------------------------------------------------------------
# check_product — verify the product is a pawlet_* target
# ---------------------------------------------------------------------------
function check_product()
{
local T=$(gettop)
if [ ! "$T" ]; then
echo "Couldn't locate the top of the tree. Try setting TOP." >&2
return
fi
if (echo -n $1 | grep -q -e "^pawlet_"); then
PAWLET_BUILD=$(echo -n $1 | sed -e 's/^pawlet_//g')
else
PAWLET_BUILD=
fi
export PAWLET_BUILD
TARGET_PRODUCT=$1 \
TARGET_BUILD_VARIANT= \
TARGET_BUILD_TYPE= \
TARGET_BUILD_APPS= \
_get_build_var_cached TARGET_DEVICE > /dev/null
}
# ---------------------------------------------------------------------------
# breakfast <device> [variant] — configure build for a PawletOS device
# ---------------------------------------------------------------------------
function breakfast()
{
local target=$1
local variant=$2
source ${ANDROID_BUILD_TOP}/vendor/pawlet/vars/aosp_target_release
if [ $# -eq 0 ]; then
lunch
else
if [[ "$target" =~ -(user|userdebug|eng)$ ]]; then
lunch $target
else
if [ -z "$variant" ]; then
variant="userdebug"
fi
lunch pawlet_${target}-${aosp_target_release}-${variant}
fi
fi
return $?
}
alias bib=breakfast
# ---------------------------------------------------------------------------
# brunch <device> — breakfast + make bacon
# ---------------------------------------------------------------------------
function brunch()
{
breakfast $*
if [ $? -eq 0 ]; then
mka bacon
else
echo "No such item in brunch menu. Try 'breakfast'"
return 1
fi
return $?
}
# ---------------------------------------------------------------------------
# mka — invoke the Android build system
# ---------------------------------------------------------------------------
function mka()
{
m "$@"
}
# ---------------------------------------------------------------------------
# cmka — clean + build a specific target
# ---------------------------------------------------------------------------
function cmka()
{
if [ ! -z "$1" ]; then
for i in "$@"; do
case $i in
bacon|otapackage|systemimage)
mka installclean
mka $i
;;
*)
mka clean-$i
mka $i
;;
esac
done
else
mka clean
mka
fi
}
# ---------------------------------------------------------------------------
# cout — cd to the output directory
# ---------------------------------------------------------------------------
function cout()
{
if [ "$OUT" ]; then
cd $OUT
else
echo "Couldn't locate out directory. Try setting OUT."
fi
}
# ---------------------------------------------------------------------------
# pawlet_img <device> — build the flashable SD-card image via mkimg.sh
# ---------------------------------------------------------------------------
function pawlet_img()
{
local device=${1:-${PAWLET_BUILD}}
if [[ -z "$device" ]]; then
echo "Usage: pawlet_img <device>"
return 1
fi
local mkimg="device/brcm/${device}/mkimg.sh"
if [[ ! -f "$mkimg" ]]; then
echo "mkimg.sh not found: $mkimg"
return 1
fi
bash "$mkimg"
}
# ---------------------------------------------------------------------------
# aospremote — add an 'aosp' git remote pointing at AOSP upstream
# ---------------------------------------------------------------------------
function aospremote()
{
local T=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$T" ]; then
echo "Git repository not found."
return 1
fi
git remote rm aosp 2>/dev/null
if [ -f "$T/.gitupstream" ]; then
local REMOTE=$(cat "$T/.gitupstream" | cut -d ' ' -f 1)
git remote add aosp ${REMOTE}
else
local PROJECT=$(pwd -P | sed -e "s#$ANDROID_BUILD_TOP/##; s#-caf.*##; s#/default##")
if [ $PROJECT = "build/make" ]; then
PROJECT="build"
fi
if (echo $PROJECT | grep -qv "^device"); then
local PFX="platform/"
fi
git remote add aosp https://android.googlesource.com/${PFX}${PROJECT}
fi
echo "Remote 'aosp' created"
}
# ---------------------------------------------------------------------------
# pawletremote — add a 'pawlet' git remote for the PawletOS Gitea instance
# ---------------------------------------------------------------------------
function pawletremote()
{
if ! git rev-parse --git-dir &>/dev/null; then
echo ".git directory not found."
return 1
fi
git remote rm pawlet 2>/dev/null
local PROJECT=$(pwd -P | sed -e "s#$ANDROID_BUILD_TOP/##; s#/#_#g" \
-e "s#platform/#android/#g")
git remote add pawlet https://git.oxmc.me/PawletOS/${PROJECT}
echo "Remote 'pawlet' created"
}
# ---------------------------------------------------------------------------
# repopick — cherry-pick patches from a Gerrit-style review server
# ---------------------------------------------------------------------------
function repopick()
{
local T=$(gettop)
$T/vendor/pawlet/build/tools/repopick.py "$@"
}
# ---------------------------------------------------------------------------
# reposync — wrapper around 'repo sync'
# ---------------------------------------------------------------------------
function reposync()
{
repo sync -j4 "$@"
}
# ---------------------------------------------------------------------------
# repodiff — show diff across all repos between two refs
# ---------------------------------------------------------------------------
function repodiff()
{
if [ -z "$*" ]; then
echo "Usage: repodiff <ref-from> [[ref-to] [--numstat]]"
return
fi
diffopts=$* repo forall -c \
'echo "$REPO_PATH ($REPO_REMOTE)"; git diff ${diffopts} 2>/dev/null;'
}
# ---------------------------------------------------------------------------
# pawlet_version — print current version info
# ---------------------------------------------------------------------------
function pawlet_version()
{
local ver=$(get_build_var PAWLET_VERSION 2>/dev/null)
local type=$(get_build_var PAWLET_BUILDTYPE 2>/dev/null)
echo "PawletOS ${ver:-unknown} (${type:-UNOFFICIAL})"
}
echo "PawletOS build helpers loaded. Try: breakfast rpi5 | brunch rpi4"
+5
View File
@@ -0,0 +1,5 @@
// Copyright (C) 2025 oxmc / PawletOS
// SPDX-License-Identifier: Apache-2.0
soong_namespace {
}
+8
View File
@@ -0,0 +1,8 @@
#
# Copyright (C) 2025 oxmc / PawletOS
# SPDX-License-Identifier: Apache-2.0
#
# bacon.mk is already included by build/core/config.mk (which AOSP picks up
# via -include vendor/*/build/core/config.mk). Do NOT include it again here
# or the bacon rule will be defined twice, causing Make warnings/errors.
#
+23
View File
@@ -0,0 +1,23 @@
#
# Copyright (C) 2012 The CyanogenMod Project
# Copyright (C) 2017 The LineageOS Project
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
# PawletOS OTA package ("bacon") build target.
# Adapted from vendor/lineage/build/tasks/bacon.mk (The LineageOS Project).
# Produces: PawletOS-<version>.zip + .sha256sum
#
PAWLET_TARGET_PACKAGE := $(PRODUCT_OUT)/PawletOS-$(PAWLET_VERSION).zip
SHA256 := prebuilts/build-tools/path/$(HOST_PREBUILT_TAG)/sha256sum
$(PAWLET_TARGET_PACKAGE): $(INTERNAL_OTA_PACKAGE_TARGET)
$(hide) ln -f $(INTERNAL_OTA_PACKAGE_TARGET) $(PAWLET_TARGET_PACKAGE)
$(hide) $(SHA256) $(PAWLET_TARGET_PACKAGE) | sed "s|$(PRODUCT_OUT)/||" > $(PAWLET_TARGET_PACKAGE).sha256sum
@echo "Package Complete: $(PAWLET_TARGET_PACKAGE)" >&2
.PHONY: bacon
bacon: $(PAWLET_TARGET_PACKAGE) $(DEFAULT_GOAL)
+37
View File
@@ -0,0 +1,37 @@
#
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
# Common board-level config inherited by every PawletOS device.
# Include from each device's BoardConfig.mk:
# include vendor/pawlet/config/BoardConfigPawlet.mk
#
# ---------------------------------------------------------------------------
# Partition sizes shared across RPi boards
# (AB_OTA_UPDATER / AB_OTA_PARTITIONS live in config/common.mk — they are
# product variables, not board variables)
# (individual devices may override these)
# ---------------------------------------------------------------------------
BOARD_BOOTIMAGE_PARTITION_SIZE := 134217728 # 128 MB
BOARD_RECOVERYIMAGE_PARTITION_SIZE := 134217728 # 128 MB — set if TWRP present
BOARD_METADATAIMAGE_PARTITION_SIZE := 67108864 # 64 MB — A/B snapshot metadata
BOARD_MISCIMAGE_PARTITION_SIZE := 4194304 # 4 MB — A/B slot tracking
# ---------------------------------------------------------------------------
# update_engine / Virtual A/B
# RPi does not support Virtual A/B (no dynamic partitions),
# so disable snapshot compression.
# ---------------------------------------------------------------------------
BOARD_USES_RECOVERY_AS_BOOT := false
# ---------------------------------------------------------------------------
# Soong config vars (must come after partition vars are set)
# ---------------------------------------------------------------------------
include vendor/pawlet/config/BoardConfigSoong.mk
# ---------------------------------------------------------------------------
# SELinux — permissive during bring-up; tighten once policies are audited
# ---------------------------------------------------------------------------
# (kept in device BoardConfig.mk so individual devices can override)
+16
View File
@@ -0,0 +1,16 @@
#
# Copyright (C) 2025 oxmc / PawletOS
# SPDX-License-Identifier: Apache-2.0
#
# Exports build variables from Make into Soong so vendor/pawlet
# modules can reference them via soong_config_variables.
#
# Export boot control block device paths so the HAL can be
# configured at build time if needed (currently runtime via props).
$(call add_soong_config_namespace,pawlet_bootcontrol)
# RPi boot partition layout
$(call soong_config_set,pawlet_bootcontrol,misc_device,/dev/block/by-name/misc)
$(call soong_config_set,pawlet_bootcontrol,boot_a_device,/dev/block/by-name/boot_a)
$(call soong_config_set,pawlet_bootcontrol,boot_b_device,/dev/block/by-name/boot_b)
+105
View File
@@ -0,0 +1,105 @@
#
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
# Common product config inherited by every PawletOS device.
# Include from each device's device.mk:
# $(call inherit-product, vendor/pawlet/config/common.mk)
#
# ---------------------------------------------------------------------------
# Soong namespace for vendor/pawlet HALs and apps
# ---------------------------------------------------------------------------
PRODUCT_SOONG_NAMESPACES += vendor/pawlet
# ---------------------------------------------------------------------------
# Brand
# ---------------------------------------------------------------------------
PRODUCT_BRAND ?= PawletOS
# ---------------------------------------------------------------------------
# Device name used in version strings (defaults to TARGET_DEVICE)
# ---------------------------------------------------------------------------
PAWLET_BUILD ?= $(TARGET_DEVICE)
# ---------------------------------------------------------------------------
# A/B (Seamless) system updates
# ---------------------------------------------------------------------------
AB_OTA_UPDATER := true
AB_OTA_PARTITIONS := \
boot \
system \
system_ext \
vendor
PRODUCT_PACKAGES += \
update_engine \
update_engine_client \
update_verifier \
android.hardware.boot-service.pawlet_rpi
PRODUCT_COPY_FILES += \
frameworks/native/data/etc/android.software.verified_boot.xml:$(TARGET_COPY_OUT_VENDOR)/etc/permissions/android.software.verified_boot.xml
# ---------------------------------------------------------------------------
# A/B: allow downgrade on non-user builds
# ---------------------------------------------------------------------------
ifneq ($(strip $(AB_OTA_PARTITIONS) $(AB_OTA_POSTINSTALL_CONFIG)),)
ifneq ($(TARGET_BUILD_VARIANT),user)
PRODUCT_PRODUCT_PROPERTIES += \
ro.ota.allow_downgrade=true
endif
endif
# ---------------------------------------------------------------------------
# ADB security
# ---------------------------------------------------------------------------
ifeq ($(TARGET_BUILD_VARIANT),eng)
PRODUCT_SYSTEM_EXT_PROPERTIES += ro.adb.secure=0
else
PRODUCT_SYSTEM_EXT_PROPERTIES += ro.adb.secure=1
PRODUCT_NOT_DEBUGGABLE_IN_USERDEBUG := true
endif
# ---------------------------------------------------------------------------
# Enforce privapp-permissions whitelist
# ---------------------------------------------------------------------------
PRODUCT_PRODUCT_PROPERTIES += \
ro.control_privapp_permissions=enforce
# ---------------------------------------------------------------------------
# This is PawletOS — declare the platform feature
# ---------------------------------------------------------------------------
PRODUCT_COPY_FILES += \
vendor/pawlet/config/permissions/me.pawlet.android.xml:$(TARGET_COPY_OUT_PRODUCT)/etc/permissions/me.pawlet.android.xml
# ---------------------------------------------------------------------------
# PawletOS platform resource package
# Exports device strings, icons, and OS metadata accessible to all apps via:
# context.createPackageContext("me.pawlet.android", 0).getResources()
# ---------------------------------------------------------------------------
PRODUCT_PACKAGES += \
PawletPlatform
# ---------------------------------------------------------------------------
# PawletOS system updater app + init rc
# ---------------------------------------------------------------------------
PRODUCT_PACKAGES += \
PawletUpdater
PRODUCT_COPY_FILES += \
vendor/pawlet/prebuilt/common/etc/init/init.pawlet-updater.rc:$(TARGET_COPY_OUT_SYSTEM_EXT)/etc/init/init.pawlet-updater.rc \
vendor/pawlet/config/permissions/privapp-permissions-PawletUpdater.xml:$(TARGET_COPY_OUT_SYSTEM_EXT)/etc/permissions/privapp-permissions-PawletUpdater.xml
# ---------------------------------------------------------------------------
# PawletOS resource overlay (framework customisations)
# ---------------------------------------------------------------------------
PRODUCT_PACKAGES += \
pawlet-framework-res-overlay
# ---------------------------------------------------------------------------
# Version properties
# ---------------------------------------------------------------------------
$(call inherit-product, vendor/pawlet/config/version.mk)
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
This feature declares that this is a PawletOS device.
-->
<permissions>
<feature name="me.pawlet.android" />
</permissions>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
Privileged permission whitelist for PawletUpdater.
Required because ro.control_privapp_permissions=enforce is set in common.mk.
Installed to /system_ext/etc/permissions/ via common.mk PRODUCT_COPY_FILES.
-->
<permissions>
<privapp-permissions package="me.pawlet.updater">
<permission name="android.permission.ACCESS_CACHE_FILESYSTEM" />
<permission name="android.permission.REBOOT" />
<permission name="android.permission.UPDATE_ENGINE" />
</privapp-permissions>
</permissions>
+52
View File
@@ -0,0 +1,52 @@
#
# Copyright (C) 2012 The CyanogenMod Project
# Copyright (C) 2017 The LineageOS Project
# Copyright (C) 2025 oxmc / PawletOS
#
# SPDX-License-Identifier: Apache-2.0
#
# Adapted from vendor/lineage/config/version.mk (The LineageOS Project).
#
PAWLET_MAJOR_VERSION := 1
PAWLET_MINOR_VERSION := 0
PAWLET_BUILD_DATE := $(shell date -u +%Y%m%d)
# Accept PAWLET_BUILDTYPE from env or RELEASE_TYPE (CI compat)
ifndef PAWLET_BUILDTYPE
ifdef RELEASE_TYPE
RELEASE_TYPE := $(shell echo $(RELEASE_TYPE) | sed -e 's|^PAWLET_||g')
PAWLET_BUILDTYPE := $(RELEASE_TYPE)
endif
endif
# Only recognised types; everything else becomes UNOFFICIAL
ifeq ($(filter RELEASE NIGHTLY SNAPSHOT EXPERIMENTAL,$(PAWLET_BUILDTYPE)),)
PAWLET_BUILDTYPE := UNOFFICIAL
PAWLET_EXTRAVERSION :=
endif
ifeq ($(PAWLET_BUILDTYPE),UNOFFICIAL)
ifneq ($(TARGET_UNOFFICIAL_BUILD_ID),)
PAWLET_EXTRAVERSION := -$(TARGET_UNOFFICIAL_BUILD_ID)
endif
endif
# Full version suffix: <date>-<type>[-extraver]-<device>
PAWLET_VERSION_SUFFIX := $(PAWLET_BUILD_DATE)-$(PAWLET_BUILDTYPE)$(PAWLET_EXTRAVERSION)-$(PAWLET_BUILD)
# Canonical version (used in OTA zip filename)
PAWLET_VERSION := $(PAWLET_MAJOR_VERSION).$(PAWLET_MINOR_VERSION)-$(PAWLET_VERSION_SUFFIX)
# Short display version (major-suffix, like LineageOS 23-<suffix>)
PAWLET_DISPLAY_VERSION := $(PAWLET_MAJOR_VERSION)-$(PAWLET_VERSION_SUFFIX)
# ---------------------------------------------------------------------------
# System properties written into /product/build.prop
# ---------------------------------------------------------------------------
PRODUCT_PRODUCT_PROPERTIES += \
ro.pawlet.version=$(PAWLET_VERSION) \
ro.pawlet.display.version=$(PAWLET_DISPLAY_VERSION) \
ro.pawlet.build.version=$(PAWLET_MAJOR_VERSION).$(PAWLET_MINOR_VERSION) \
ro.pawlet.releasetype=$(PAWLET_BUILDTYPE)
+25
View File
@@ -0,0 +1,25 @@
// Copyright (C) 2025 oxmc / PawletOS
//
// SPDX-License-Identifier: Apache-2.0
cc_binary {
name: "android.hardware.boot-service.pawlet_rpi",
relative_install_path: "hw",
vendor: true,
srcs: [
"BootControl.cpp",
"service.cpp",
],
shared_libs: [
"android.hardware.boot-V3-ndk",
"libbase",
"libbinder_ndk",
"liblog",
"libz",
],
init_rc: ["android.hardware.boot-service.pawlet_rpi.rc"],
vintf_fragments: ["android.hardware.boot-service.pawlet_rpi.xml"],
}
+347
View File
@@ -0,0 +1,347 @@
/*
* Copyright (C) The Android Open Source Project
* Copyright (C) 2025 oxmc / PawletOS
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "BootControl.h"
#include <algorithm>
#include <vector>
#include <fcntl.h>
#include <linux/fs.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <zlib.h>
#include <android-base/logging.h>
#include <android-base/properties.h>
#include <android-base/unique_fd.h>
namespace aidl::android::hardware::boot {
static constexpr size_t kCopyBlockSize = 1u * 1024u * 1024u; // 1 MiB
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
BootControl::BootControl(const char* misc,
const char* boot_a,
const char* boot_b)
: misc_device_(misc),
boot_a_device_(boot_a),
boot_b_device_(boot_b) {}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/* static */
uint32_t BootControl::computeCrc32(const void* data, size_t len) {
return ::crc32(0, reinterpret_cast<const Bytef*>(data), len);
}
/* static */
bool BootControl::isValidSlot(int32_t slot) {
return slot == 0 || slot == 1;
}
void BootControl::initDefaultMisc(PawletBootControl* ctrl) {
memset(ctrl, 0, sizeof(*ctrl));
ctrl->magic = PAWLET_MAGIC;
ctrl->version = PAWLET_VERSION;
ctrl->active_slot = 0;
ctrl->merge_status = static_cast<uint8_t>(MergeStatus::NONE);
// Slot A: high priority, fresh attempts, not yet confirmed successful.
ctrl->slots[0] = {15, static_cast<uint8_t>(PAWLET_MAX_BOOT_ATTEMPTS), 0, 0};
// Slot B: low priority until activated.
ctrl->slots[1] = {0, static_cast<uint8_t>(PAWLET_MAX_BOOT_ATTEMPTS), 0, 0};
}
bool BootControl::readMisc(PawletBootControl* ctrl) {
android::base::unique_fd fd(
open(misc_device_.c_str(), O_RDONLY | O_CLOEXEC));
if (fd.get() < 0) {
PLOG(ERROR) << "Cannot open misc for reading: " << misc_device_;
return false;
}
if (lseek(fd.get(), PAWLET_MISC_OFFSET, SEEK_SET) < 0) {
PLOG(ERROR) << "lseek failed on " << misc_device_;
return false;
}
if (read(fd.get(), ctrl, sizeof(*ctrl)) != static_cast<ssize_t>(sizeof(*ctrl))) {
PLOG(ERROR) << "Short read from " << misc_device_;
initDefaultMisc(ctrl);
return true; // Return safe defaults; writeMisc will persist later.
}
if (ctrl->magic != PAWLET_MAGIC || ctrl->version != PAWLET_VERSION) {
LOG(WARNING) << "misc: invalid magic/version — initialising defaults";
initDefaultMisc(ctrl);
return true;
}
// Verify CRC32 over all bytes except the crc32 field itself.
uint32_t stored_crc = ctrl->crc32;
ctrl->crc32 = 0;
uint32_t computed = computeCrc32(ctrl, sizeof(*ctrl) - sizeof(uint32_t));
ctrl->crc32 = stored_crc;
if (stored_crc != computed) {
LOG(WARNING) << "misc: CRC mismatch (stored=0x" << std::hex << stored_crc
<< " computed=0x" << computed << ") — using defaults";
initDefaultMisc(ctrl);
}
return true;
}
bool BootControl::writeMisc(const PawletBootControl& in) {
PawletBootControl ctrl = in;
ctrl.crc32 = 0;
ctrl.crc32 = computeCrc32(&ctrl, sizeof(ctrl) - sizeof(uint32_t));
android::base::unique_fd fd(
open(misc_device_.c_str(), O_WRONLY | O_CLOEXEC));
if (fd.get() < 0) {
PLOG(ERROR) << "Cannot open misc for writing: " << misc_device_;
return false;
}
if (lseek(fd.get(), PAWLET_MISC_OFFSET, SEEK_SET) < 0) {
PLOG(ERROR) << "lseek failed on " << misc_device_;
return false;
}
if (write(fd.get(), &ctrl, sizeof(ctrl)) != static_cast<ssize_t>(sizeof(ctrl))) {
PLOG(ERROR) << "Short write to " << misc_device_;
return false;
}
fsync(fd.get());
return true;
}
bool BootControl::copyBootPartition(const std::string& src,
const std::string& dst) {
android::base::unique_fd src_fd(open(src.c_str(), O_RDONLY | O_CLOEXEC));
if (src_fd.get() < 0) {
PLOG(ERROR) << "Cannot open source boot partition: " << src;
return false;
}
android::base::unique_fd dst_fd(open(dst.c_str(), O_WRONLY | O_CLOEXEC));
if (dst_fd.get() < 0) {
PLOG(ERROR) << "Cannot open dest boot partition: " << dst;
return false;
}
uint64_t size = 0;
if (ioctl(src_fd.get(), BLKGETSIZE64, &size) < 0) {
PLOG(ERROR) << "BLKGETSIZE64 failed on " << src;
return false;
}
LOG(INFO) << "Syncing boot partition " << src << " -> " << dst
<< " (" << size << " bytes)";
std::vector<uint8_t> buf(kCopyBlockSize);
uint64_t copied = 0;
while (copied < size) {
size_t to_read = std::min(static_cast<uint64_t>(buf.size()), size - copied);
ssize_t n = read(src_fd.get(), buf.data(), to_read);
if (n <= 0) {
PLOG(ERROR) << "Read error at offset " << copied << " on " << src;
return false;
}
ssize_t w = write(dst_fd.get(), buf.data(), static_cast<size_t>(n));
if (w != n) {
PLOG(ERROR) << "Write error at offset " << copied << " on " << dst;
return false;
}
copied += static_cast<uint64_t>(n);
}
fsync(dst_fd.get());
LOG(INFO) << "Boot partition sync complete (" << copied << " bytes)";
return true;
}
// ---------------------------------------------------------------------------
// IBootControl implementation
// ---------------------------------------------------------------------------
ndk::ScopedAStatus BootControl::getNumberSlots(int32_t* out) {
*out = 2;
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::getCurrentSlot(int32_t* out) {
// The RPi firmware writes the booted slot into the kernel cmdline via
// androidboot.slot_suffix, which init propagates to ro.boot.slot_suffix.
const std::string suffix =
android::base::GetProperty("ro.boot.slot_suffix", "");
if (suffix == "_b") {
*out = 1;
} else {
// Default to slot A (covers both "_a" and the uninitialised case).
*out = 0;
}
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::getActiveBootSlot(int32_t* out) {
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
*out = static_cast<int32_t>(ctrl.active_slot);
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::getSuffix(int32_t slot, std::string* out) {
if (!isValidSlot(slot)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_ARGUMENT);
}
*out = (slot == 0) ? "_a" : "_b";
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::markBootSuccessful() {
int32_t current_slot = 0;
getCurrentSlot(&current_slot);
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
ctrl.slots[current_slot].successful_boot = 1;
ctrl.slots[current_slot].tries_remaining =
static_cast<uint8_t>(PAWLET_MAX_BOOT_ATTEMPTS);
ctrl.active_slot = static_cast<uint8_t>(current_slot);
if (!writeMisc(ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
// RPi-specific: after a successful slot-B boot, copy boot_b -> boot_a so
// that subsequent normal reboots (which always start from partition 1 /
// boot_a) also run the new software.
if (current_slot == 1) {
LOG(INFO) << "Slot B verified — syncing boot_b -> boot_a";
if (!copyBootPartition(boot_b_device_, boot_a_device_)) {
// Non-fatal: the update is still live; we just can't guarantee
// persistence after a non-tryboot reboot.
LOG(ERROR) << "boot_b -> boot_a sync failed; update may be lost on "
"next normal reboot";
}
}
LOG(INFO) << "Slot " << current_slot << " marked as successful";
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::setActiveBootSlot(int32_t slot) {
if (!isValidSlot(slot)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_ARGUMENT);
}
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
ctrl.active_slot = static_cast<uint8_t>(slot);
// Activate the target slot with full priority and attempts.
ctrl.slots[slot].priority = 15;
ctrl.slots[slot].tries_remaining =
static_cast<uint8_t>(PAWLET_MAX_BOOT_ATTEMPTS);
ctrl.slots[slot].successful_boot = 0;
// Demote the other slot so it is still bootable but lower priority.
int32_t other = slot ^ 1;
if (ctrl.slots[other].priority > 7) {
ctrl.slots[other].priority = 7;
}
if (!writeMisc(ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
LOG(INFO) << "Active boot slot set to " << slot
<< " (suffix: " << (slot == 0 ? "_a" : "_b") << ")";
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::setSlotAsUnbootable(int32_t slot) {
if (!isValidSlot(slot)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_ARGUMENT);
}
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
ctrl.slots[slot].priority = 0;
ctrl.slots[slot].tries_remaining = 0;
ctrl.slots[slot].successful_boot = 0;
if (!writeMisc(ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
LOG(WARNING) << "Slot " << slot << " marked as unbootable";
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::isSlotBootable(int32_t slot, bool* out) {
if (!isValidSlot(slot)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_ARGUMENT);
}
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
*out = (ctrl.slots[slot].priority > 0) &&
(ctrl.slots[slot].tries_remaining > 0);
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::isSlotMarkedSuccessful(int32_t slot, bool* out) {
if (!isValidSlot(slot)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_ARGUMENT);
}
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
*out = (ctrl.slots[slot].successful_boot == 1);
return ndk::ScopedAStatus::ok();
}
// RPi does not use Virtual A/B snapshots, but we must implement these
// methods so update_engine can track merge state generically.
ndk::ScopedAStatus BootControl::getSnapshotMergeStatus(MergeStatus* out) {
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
*out = static_cast<MergeStatus>(ctrl.merge_status);
return ndk::ScopedAStatus::ok();
}
ndk::ScopedAStatus BootControl::setSnapshotMergeStatus(MergeStatus status) {
PawletBootControl ctrl{};
if (!readMisc(&ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
ctrl.merge_status = static_cast<uint8_t>(status);
if (!writeMisc(ctrl)) {
return ndk::ScopedAStatus::fromExceptionCode(EX_ILLEGAL_STATE);
}
return ndk::ScopedAStatus::ok();
}
} // namespace aidl::android::hardware::boot
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright (C) The Android Open Source Project
* Copyright (C) 2025 oxmc / PawletOS
*
* SPDX-License-Identifier: Apache-2.0
*
* Implements the AOSP android.hardware.boot AIDL interface
* (hardware/interfaces/boot/) for Raspberry Pi 4/5.
*/
#pragma once
#include <aidl/android/hardware/boot/BnBootControl.h>
#include <aidl/android/hardware/boot/MergeStatus.h>
#include "misc_rpi.h"
namespace aidl::android::hardware::boot {
/**
* PawletOS boot_control AIDL HAL for Raspberry Pi 4 / 5.
*
* Slot metadata is persisted in the "misc" GPT partition at
* PAWLET_MISC_OFFSET (see misc_rpi.h).
*
* The RPi firmware's tryboot mechanism is used for slot switching:
* setActiveBootSlot(1) — writes pending-B to misc
* (caller triggers `reboot tryboot`)
* markBootSuccessful() — confirms boot; if current slot is B, copies
* boot_b → boot_a to make the change permanent
*/
class BootControl : public BnBootControl {
public:
BootControl(const char* misc_device,
const char* boot_a_device,
const char* boot_b_device);
// IBootControl
ndk::ScopedAStatus getNumberSlots(int32_t* out) override;
ndk::ScopedAStatus getCurrentSlot(int32_t* out) override;
ndk::ScopedAStatus getActiveBootSlot(int32_t* out) override;
ndk::ScopedAStatus getSuffix(int32_t slot, std::string* out) override;
ndk::ScopedAStatus markBootSuccessful() override;
ndk::ScopedAStatus setActiveBootSlot(int32_t slot) override;
ndk::ScopedAStatus setSlotAsUnbootable(int32_t slot) override;
ndk::ScopedAStatus isSlotBootable(int32_t slot, bool* out) override;
ndk::ScopedAStatus isSlotMarkedSuccessful(int32_t slot, bool* out) override;
ndk::ScopedAStatus getSnapshotMergeStatus(MergeStatus* out) override;
ndk::ScopedAStatus setSnapshotMergeStatus(MergeStatus status) override;
private:
const std::string misc_device_;
const std::string boot_a_device_;
const std::string boot_b_device_;
bool readMisc(PawletBootControl* ctrl);
bool writeMisc(const PawletBootControl& ctrl);
void initDefaultMisc(PawletBootControl* ctrl);
bool copyBootPartition(const std::string& src, const std::string& dst);
static uint32_t computeCrc32(const void* data, size_t len);
static bool isValidSlot(int32_t slot);
};
} // namespace aidl::android::hardware::boot
@@ -0,0 +1,6 @@
service vendor.boot_control-pawlet_rpi /vendor/bin/hw/android.hardware.boot-service.pawlet_rpi
class hal
user root
group root
# Restart update_engine if the HAL crashes — it may be mid-update.
onrestart restart update_engine
@@ -0,0 +1,7 @@
<manifest version="1.0" type="device">
<hal format="aidl">
<name>android.hardware.boot</name>
<version>3</version>
<fqname>IBootControl/default</fqname>
</hal>
</manifest>
+60
View File
@@ -0,0 +1,60 @@
/*
* Copyright (C) The Android Open Source Project
* Copyright (C) 2025 oxmc / PawletOS
*
* SPDX-License-Identifier: Apache-2.0
*
* On-disk metadata format for PawletOS A/B boot control.
*
* Layout in the misc partition (4 MB, GPT label "misc"):
* 0x0000 0x07FF Android Bootloader Control Block (BCB) — do NOT touch
* 0x0800 0x0FFF Unused padding
* 0x1000 (4096) PawletBootControl — read/written by this HAL
*
* Slot numbering: 0 = slot A (boot_a, cmdline: androidboot.slot_suffix=_a)
* 1 = slot B (boot_b, cmdline: androidboot.slot_suffix=_b)
*
* A/B boot flow on RPi:
* - Normal boot always loads boot_a (GPT partition 1) via the RPi firmware.
* - To boot slot B once: `reboot tryboot` causes firmware to use
* boot_partition=2 for one attempt (see [tryboot] in config.txt).
* - On a confirmed-successful boot from slot B, markBootSuccessful() copies
* boot_b → boot_a so that subsequent normal reboots use the new content.
*/
#pragma once
#include <stdint.h>
#include <string.h>
// Magic value: "PAWL" in little-endian ASCII.
static constexpr uint32_t PAWLET_MAGIC = 0x4C574150u;
static constexpr uint32_t PAWLET_VERSION = 1u;
// Byte offset inside the misc partition where our struct lives.
static constexpr off_t PAWLET_MISC_OFFSET = 4096;
// How many boot attempts to grant a freshly-activated slot.
static constexpr int PAWLET_MAX_BOOT_ATTEMPTS = 3;
// Per-slot metadata (4 bytes).
struct PawletSlot {
uint8_t priority; // 015. Higher = preferred. 0 = unbootable.
uint8_t tries_remaining; // Remaining attempts before slot is unbootable.
uint8_t successful_boot; // 1 once markBootSuccessful() has been called.
uint8_t reserved;
};
// Complete on-disk structure placed at PAWLET_MISC_OFFSET.
// Total size: 24 bytes.
struct __attribute__((packed)) PawletBootControl {
uint32_t magic; // PAWLET_MAGIC
uint32_t version; // PAWLET_VERSION
uint8_t active_slot; // 0 = A, 1 = B
uint8_t merge_status; // android.hardware.boot.MergeStatus enum value
uint8_t reserved[2];
PawletSlot slots[2]; // [0] = slot A, [1] = slot B
uint32_t crc32; // CRC32 over all preceding bytes
};
static_assert(sizeof(PawletBootControl) == 24,
"PawletBootControl layout changed update tests and partition docs");
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright (C) The Android Open Source Project
* Copyright (C) 2025 oxmc / PawletOS
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <android-base/logging.h>
#include <android/binder_manager.h>
#include <android/binder_process.h>
#include "BootControl.h"
using aidl::android::hardware::boot::BootControl;
using aidl::android::hardware::boot::IBootControl;
// Block device paths — resolved at runtime via the GPT "by-name" symlinks
// that ueventd creates from the partition labels set by mkimg.sh / sgdisk.
static constexpr const char* kMiscDevice = "/dev/block/by-name/misc";
static constexpr const char* kBootADevice = "/dev/block/by-name/boot_a";
static constexpr const char* kBootBDevice = "/dev/block/by-name/boot_b";
int main() {
android::base::InitLogging(nullptr, android::base::KernelLogger);
// Single-threaded is sufficient — update_engine calls are serialised.
ABinderProcess_setThreadPoolMaxThreadCount(0);
auto bc = ndk::SharedRefBase::make<BootControl>(
kMiscDevice, kBootADevice, kBootBDevice);
const std::string instance =
std::string(IBootControl::descriptor) + "/default";
binder_status_t status =
AServiceManager_addService(bc->asBinder().get(), instance.c_str());
if (status != STATUS_OK) {
LOG(FATAL) << "Failed to register " << instance
<< " (binder status=" << status << ")";
return 1;
}
LOG(INFO) << "PawletOS boot_control HAL ready: " << instance;
ABinderProcess_joinThreadPool();
// joinThreadPool only returns on a fatal error.
return EXIT_FAILURE;
}
@@ -0,0 +1,13 @@
// Copyright (C) 2025 oxmc / PawletOS
//
// SPDX-License-Identifier: Apache-2.0
runtime_resource_overlay {
name: "pawlet-framework-res-overlay",
// Targets the android framework-res package.
// Only use this for OVERRIDING existing framework config values
// (config_*, colors, booleans). Do NOT add new resources here —
// they will not be accessible to apps. Use PawletPlatform for that.
resource_dirs: ["res"],
product_specific: true,
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 oxmc / PawletOS
SPDX-License-Identifier: Apache-2.0
PawletOS framework configuration overrides.
These values override the defaults in frameworks/base/core/res/res/values/config.xml.
Only put framework config overrides here, NOT new resources.
-->
<resources>
<!-- Disable network location provider (RPi has no GPS/cell radio) -->
<bool name="config_enableNetworkLocationOverlay" translatable="false">false</bool>
<bool name="config_enableFusedLocationOverlay" translatable="false">false</bool>
<!-- RPi has no telephony -->
<bool name="config_voice_capable">false</bool>
<bool name="config_sms_capable">false</bool>
<!-- Ethernet is the primary connectivity on RPi -->
<integer name="config_networkTransitionTimeout">0</integer>
<!-- No hardware camera on base RPi boards -->
<bool name="config_hasRecents">false</bool>
</resources>
@@ -0,0 +1,4 @@
on post-fs-data
# Create OTA download directory for PawletUpdater.
# Unlike /data/ota_package this won't be touched by GmsCore.
mkdir /data/pawlet_updates 0770 system cache encryption=Require
+2
View File
@@ -0,0 +1,2 @@
# Updated manually - tracks the current AOSP target release tag
aosp_target_release=bp4a