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:
@@ -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
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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.
|
||||
#
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright (C) 2025 oxmc / PawletOS
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
soong_namespace {
|
||||
}
|
||||
@@ -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.
|
||||
#
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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(¤t_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
|
||||
@@ -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>
|
||||
@@ -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; // 0–15. 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");
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Updated manually - tracks the current AOSP target release tag
|
||||
aosp_target_release=bp4a
|
||||
Reference in New Issue
Block a user