Skip to content
Merged
20 changes: 20 additions & 0 deletions .github/workflows/flutter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Flutter CI

on:
pull_request:
push:
branches:
- master

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: flutter analyze
- run: flutter test
80 changes: 49 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,64 @@
# Backup Android App
# Android Partition Backup

This is a flutter app that enables you to back up your android partitions using root and dd.
Please have adb installed globally on your computer. (`adb` should be in your PATH)
## Features
A Flutter desktop utility for backing up rooted Android partitions with ADB and restoring verified images with Fastboot.

- Backup partitions
- Clean UI
## Safety model

## Screenshots
Raw partition operations are inherently risky. The app now scopes ADB/Fastboot commands to an explicitly selected device serial and does not treat a backup as successful until the local image has been verified.

![Screenshot 1](img.png)
Each successful backup records:

## How to use
- device identity and serial
- partition name and source layout
- exact partition byte size
- SHA-256 of the local image
- backup timestamp and Android/device metadata

- Install the app
- Grant root access to `com.android.shell`
- Ensure developer options is enabled with USB debugging
- Connect your phone to your computer
- Open the app
- Press refresh to see the partitions
- Select the partition you want to backup
- Select a location to save the backup
- Click on the backup button
- Wait for the backup to complete
- Done
The metadata is written to `backup_manifest.json` in the backup folder. The restore screen only accepts folders containing a valid manifest, verifies every image before flashing, compares the target device with the manifest, and requires typed confirmation for destructive operations.

## How to build
## Requirements

- Clone the repository
- `flutter pub get`
- `flutter build windows`
- Flutter for building the desktop application
- Android SDK Platform Tools (`adb` and `fastboot`) available in `PATH`
- USB debugging enabled for backups
- root access available to the ADB shell for raw partition reads
- an unlocked bootloader / appropriate Fastboot state when restoring images

## License
## Backing up partitions

MIT
1. Connect the Android device with USB debugging enabled.
2. Authorize the computer on the device.
3. Open the app and select the target ADB serial if more than one device is connected.
4. Confirm that root access and the partition list are detected.
5. Choose a backup folder.
6. Select the partitions to back up.
7. Click **Backup Selected**.
8. Keep the device connected until each selected image is reported as verified.

`userdata` is intentionally skipped by Select All backups. If a transfer or verification fails after the temporary device image was created, the app retains that temporary file and reports its path rather than deleting the only potentially recoverable copy.

## Restoring partitions

1. Put the target device into the bootloader/Fastboot interface.
2. Open **Flash Partitions**.
3. Select the target Fastboot serial.
4. Select the backup folder containing `backup_manifest.json`.
5. Use **Verify & Flash** for one partition or **Flash All Verified** for the complete manifest set.
6. Review any serial/product mismatch warning carefully and enter the requested confirmation phrase.

Flash All stops on the first Fastboot failure. Wipe, bootloader lock, bootloader unlock, and flashing operations require explicit serial-specific confirmation.

## Build

```bash
flutter pub get
flutter build windows
```
Copyright 2024 Andy Wang

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The project also contains automated tests for command construction, device parsing, backup manifests, and basic UI rendering. GitHub Actions runs `flutter analyze` and `flutter test` for pull requests.

## License

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
MIT

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
Copyright 2024 Andy Wang
7 changes: 4 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ void main() {
class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
title: 'Android Partition Backup',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal, brightness: Brightness.dark),
seedColor: Colors.teal,
brightness: Brightness.dark,
),
useMaterial3: true,
),
debugShowCheckedModeBanner: false,
Expand Down
44 changes: 36 additions & 8 deletions lib/models/backup_manifest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,29 @@ class PartitionBackup {
};

factory PartitionBackup.fromJson(Map<String, dynamic> json) {
final name = json['name'] as String;
final file = json['file'] as String;
final size = json['size'] as int;
final hash = json['sha256'] as String;

if (!RegExp(r'^[A-Za-z0-9._-]+$').hasMatch(name)) {
throw const FormatException('Manifest contains an invalid partition name.');
}
if (path.basename(file) != file || !file.endsWith('.img')) {
throw const FormatException('Manifest contains an unsafe image path.');
}
if (size <= 0) {
throw const FormatException('Manifest contains an invalid image size.');
}
if (!RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(hash)) {
throw const FormatException('Manifest contains an invalid SHA-256 value.');
}

return PartitionBackup(
name: json['name'] as String,
file: json['file'] as String,
size: json['size'] as int,
sha256: json['sha256'] as String,
name: name,
file: file,
size: size,
sha256: hash.toLowerCase(),
);
}

Expand Down Expand Up @@ -101,9 +119,21 @@ class BackupManifest {
throw const FormatException('Unsupported backup manifest version.');
}

final serial = json['serial'] as String;
final partitionEntries = (json['partitions'] as List<dynamic>)
.map((entry) => PartitionBackup.fromJson(entry as Map<String, dynamic>))
.toList();
if (serial.trim().isEmpty) {
throw const FormatException('Backup manifest does not identify a device serial.');
}
final names = partitionEntries.map((entry) => entry.name).toSet();
if (names.length != partitionEntries.length) {
throw const FormatException('Backup manifest contains duplicate partitions.');
}

return BackupManifest(
createdAt: DateTime.parse(json['createdAt'] as String),
serial: json['serial'] as String,
serial: serial,
model: json['model'] as String,
manufacturer: json['manufacturer'] as String,
brand: json['brand'] as String,
Expand All @@ -113,9 +143,7 @@ class BackupManifest {
androidVersion: json['androidVersion'] as String,
slot: json['slot'] as String,
partitionDirectory: json['partitionDirectory'] as String,
partitions: (json['partitions'] as List<dynamic>)
.map((entry) => PartitionBackup.fromJson(entry as Map<String, dynamic>))
.toList(),
partitions: partitionEntries,
);
}

Expand Down
6 changes: 3 additions & 3 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ publish_to: 'none'
version: 1.0.0+1

environment:
sdk: '>=3.3.0 <4.0.0'
sdk: '>=3.4.0 <4.0.0'

dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
file_picker: ^8.0.0+1
file_picker: ^10.3.10
crypto: ^3.0.3
url_launcher: ^6.2.6
url_launcher: ^6.3.2
path: ^1.9.0

dev_dependencies:
Expand Down
54 changes: 54 additions & 0 deletions test/adb_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:backup_partitions/services/adb_service.dart';
import 'package:flutter_test/flutter_test.dart';

import 'fake_process_runner.dart';

void main() {
test('listDevices parses authorized and unauthorized devices', () async {
final runner = FakeProcessRunner([
fakeResult(
stdout: 'List of devices attached\nABC123\tdevice\nDEF456\tunauthorized\n\n',
),
]);
final service = AdbService(runner: runner);

final devices = await service.listDevices();

expect(devices, hasLength(2));
expect(devices[0].serial, 'ABC123');
expect(devices[0].isAuthorized, isTrue);
expect(devices[1].serial, 'DEF456');
expect(devices[1].state, 'unauthorized');
});

test('device commands are scoped to the requested serial', () async {
final runner = FakeProcessRunner([
fakeResult(stdout: 'Pixel 8\n'),
]);
final service = AdbService(runner: runner);

final model = await service.getProp('ABC123', 'ro.product.model');

expect(model, 'Pixel 8');
expect(runner.commands.single.executable, 'adb');
expect(
runner.commands.single.arguments,
['-s', 'ABC123', 'shell', 'getprop', 'ro.product.model'],
);
});

test('partition directory probing falls back to supported layouts', () async {
final runner = FakeProcessRunner([
fakeResult(exitCode: 1, stderr: 'missing'),
fakeResult(stdout: 'ok\n'),
]);
final service = AdbService(runner: runner);

final directory = await service.findPartitionDirectory('ABC123');

expect(directory, '/dev/block/by-name');
expect(runner.commands, hasLength(2));
expect(runner.commands[0].arguments.take(4), ['-s', 'ABC123', 'shell', 'su']);
expect(runner.commands[1].arguments.last, contains('/dev/block/by-name'));
});
}
64 changes: 64 additions & 0 deletions test/backup_manifest_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'dart:io';

import 'package:backup_partitions/models/backup_manifest.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
test('manifest round-trips and detects image corruption', () async {
final directory = await Directory.systemTemp.createTemp('backup_partitions_test_');
addTearDown(() async {
if (await directory.exists()) {
await directory.delete(recursive: true);
}
});

final image = File('${directory.path}${Platform.pathSeparator}boot.img');
await image.writeAsBytes([1, 2, 3, 4, 5], flush: true);
final hash = await sha256File(image);

final manifest = BackupManifest(
createdAt: DateTime.utc(2026, 8, 31),
serial: 'ABC123',
model: 'Pixel',
manufacturer: 'Google',
brand: 'google',
device: 'panther',
product: 'panther',
fingerprint: 'google/panther/test',
androidVersion: '16',
slot: 'a',
partitionDirectory: '/dev/block/by-name',
partitions: [
PartitionBackup(
name: 'boot',
file: 'boot.img',
size: await image.length(),
sha256: hash,
),
],
);

await manifest.writeTo(directory);
final loaded = await BackupManifest.readFrom(directory);

expect(loaded, isNotNull);
expect(loaded!.serial, 'ABC123');
expect(loaded.partitions.single.name, 'boot');
expect(await loaded.partitions.single.verify(directory), isTrue);

await image.writeAsBytes([9, 9, 9], flush: true);
expect(await loaded.partitions.single.verify(directory), isFalse);
});

test('partition manifest rejects unsafe image paths', () {
expect(
() => PartitionBackup.fromJson({
'name': 'boot',
'file': '../boot.img',
'size': 4096,
'sha256': 'a' * 64,
}),
throwsFormatException,
);
});
}
35 changes: 35 additions & 0 deletions test/fake_process_runner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'dart:io';

import 'package:backup_partitions/services/process_runner.dart';

class RecordedCommand {
const RecordedCommand(this.executable, this.arguments);

final String executable;
final List<String> arguments;
}

class FakeProcessRunner implements ProcessRunner {
FakeProcessRunner(this.results);

final List<ProcessResult> results;
final List<RecordedCommand> commands = [];
int _index = 0;

@override
Future<ProcessResult> run(String executable, List<String> arguments) async {
commands.add(RecordedCommand(executable, List<String>.of(arguments)));
if (_index >= results.length) {
throw StateError('No fake result queued for $executable ${arguments.join(' ')}');
}
return results[_index++];
}
}

ProcessResult fakeResult({
int exitCode = 0,
String stdout = '',
String stderr = '',
}) {
return ProcessResult(1, exitCode, stdout, stderr);
}
Loading
Loading