1
0
forked from firka/firka

android: compress flutter resources

Flutter allows for replacing the default asset bundle with a
custom one, so we can just compress the resources with brotli /
gzip or keep it as is (which ever one is smaller), and then
decompress it inside our custom asset bundle class.
This commit is contained in:
2025-08-18 15:34:24 +02:00
parent af307bd784
commit ac59bac328
10 changed files with 340 additions and 115 deletions

View File

@@ -4,6 +4,7 @@ import java.security.MessageDigest
import java.util.Properties
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.locks.ReentrantReadWriteLock
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import java.util.zip.ZipOutputStream.DEFLATED
@@ -206,11 +207,13 @@ fun transformAndSignApk(apkDir: File, name: String, debug: Boolean) {
fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
val tempDir = File(project.buildDir, "tmp/apk-transform")
val cacheDir = File(project.buildDir, "apk-transform-cache")
val cacheDir = File(project.buildDir, "cache")
val optipngCacheDir = File(cacheDir, "optipng")
val assetCompressionDir = File(cacheDir, "assets")
tempDir.deleteRecursively()
tempDir.mkdirs()
if (!optipngCacheDir.exists()) optipngCacheDir.mkdirs()
if (!assetCompressionDir.exists()) assetCompressionDir.mkdirs()
val brotli = findToolInPath("brotli")
?: throw Exception("Brotli not found in path")
@@ -264,30 +267,159 @@ fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
val zos = ZipOutputStream(output.outputStream())
val coreCount = Runtime.getRuntime().availableProcessors()
val flutterResources = tempDir.walkTopDown().filter{f -> f.absolutePath.contains("flutter_assets")}
val pngFiles = tempDir.walkTopDown().filter{f -> f.name.endsWith(".png")}
if (compressionLevel == "Z" && optipng != null) {
val assetIndex = mutableMapOf<String, String>()
val indexReadWriteLock = ReentrantReadWriteLock()
if (compressionLevel == "Z") {
if (optipng != null) {
val executor = Executors.newFixedThreadPool(coreCount)
val futures = mutableListOf<Future<*>>()
pngFiles.forEach { pngFile ->
val cacheFile = File(optipngCacheDir, pngFile.sha256())
if (cacheFile.exists()) {
cacheFile.copyTo(pngFile, true)
} else {
val future = executor.submit {
exec {
commandLine(
optipng,
"-zm", "9",
"-zw", "32k",
"-o9",
pngFile.absolutePath
)
}
pngFile.copyTo(cacheFile, true)
}
futures.add(future)
}
}
futures.forEach { it.get() }
executor.shutdown()
}
val executor = Executors.newFixedThreadPool(coreCount)
val futures = mutableListOf<Future<*>>()
pngFiles.forEach { pngFile ->
val cacheFile = File(optipngCacheDir, pngFile.sha256())
val blacklist = listOf(
// "AssetManifest.bin",
"AssetManifest.json",
"FontManifest.json",
"isolate_snapshot_data",
"kernel_blob.bin",
"NativeAssetsManifest.json",
"NOTICES.Z",
"vm_snapshot_data",
"fonts",
"shaders"
)
if (cacheFile.exists()) {
cacheFile.copyTo(pngFile, true)
flutterResources.forEach { f ->
val relName = f.absolutePath.substring(topDirL).replace("\\", "/")
if (f.isDirectory) return@forEach
val cacheFileRaw = File(assetCompressionDir, f.sha256()+".r")
val cacheFileGz = File(assetCompressionDir, f.sha256()+".gz")
val cacheFileBr = File(assetCompressionDir, f.sha256()+".br")
if (cacheFileRaw.exists() || cacheFileGz.exists() || cacheFileBr.exists()) {
if (cacheFileRaw.exists()) {
cacheFileRaw.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "r"
indexReadWriteLock.writeLock().unlock()
} else if (cacheFileGz.exists()) {
cacheFileGz.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "g"
indexReadWriteLock.writeLock().unlock()
} else {
cacheFileBr.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "b"
indexReadWriteLock.writeLock().unlock()
}
} else {
val future = executor.submit {
exec {
commandLine(
optipng,
"-zm", "9",
"-zw", "32k",
"-o9",
pngFile.absolutePath
)
val brTmp = File(f.absolutePath + ".br.tmp")
val gzTmp = File(f.absolutePath + ".gz.tmp")
var blacklisted = false
for (f in blacklist) {
if (relName.contains(f)) {
blacklisted = true
break
}
}
pngFile.copyTo(cacheFile, true)
if (!blacklisted) {
println("$relName: Testing with brotli")
exec {
commandLine(
brotli,
"-$compressionLevel",
f.absolutePath,
"-o", brTmp.absolutePath
)
}
println("$relName: Testing with gzip")
ant.invokeMethod(
"gzip", mapOf(
"src" to f.absolutePath,
"destfile" to gzTmp.absolutePath,
)
)
println("$brTmp: ${brTmp.length()}")
println("$gzTmp: ${gzTmp.length()}")
if (f.length() < gzTmp.length() && f.length() < brTmp.length()) {
println("$relName: Raw file wins")
f.copyTo(cacheFileRaw, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "r"
indexReadWriteLock.writeLock().unlock()
} else {
if (brTmp.length() < gzTmp.length()) {
println("$relName: Brotli wins")
f.delete()
brTmp.copyTo(f, true)
brTmp.copyTo(cacheFileBr, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "b"
indexReadWriteLock.writeLock().unlock()
} else {
println("$relName: Gzip wins")
f.delete()
gzTmp.copyTo(f, true)
gzTmp.copyTo(cacheFileGz, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "g"
indexReadWriteLock.writeLock().unlock()
}
}
brTmp.delete()
gzTmp.delete()
}
}
futures.add(future)
@@ -304,6 +436,12 @@ fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
var relName = f.absolutePath.substring(topDirL).replace("\\", "/")
if (f.isDirectory && !relName.endsWith("/")) relName += "/"
if (compressionLevel == "Z") {
if (relName == "assets/flutter_assets/assets/firka.i") return@forEach
}
println(relName)
val compress = !relName.endsWith(".so") && !relName.endsWith(".arsc")
zos.setMethod(if (compress) { DEFLATED } else { STORED })
val entry = ZipEntry(relName)
@@ -317,13 +455,34 @@ fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
}
zos.closeEntry()
}
zos.close()
if (compressionLevel == "Z") {
zos.setMethod(DEFLATED)
zos.putNextEntry(ZipEntry("assets/flutter_assets/assets/firka.i"))
ant.invokeMethod("zip", mapOf(
"destfile" to output.absolutePath,
"basedir" to tempDir.absolutePath,
"level" to 0
))
val indexUncompressed = File(tempDir, "index.json")
indexReadWriteLock.readLock().lock()
val json = groovy.json.JsonBuilder(assetIndex)
indexReadWriteLock.readLock().unlock()
indexUncompressed.writeText(json.toString())
val indexCompressed = File(tempDir, "index.json.br")
exec {
commandLine(
brotli,
"-$compressionLevel",
indexUncompressed.absolutePath,
"-o", indexCompressed.absolutePath
)
}
zos.write(indexCompressed.readBytes())
indexUncompressed.delete()
indexCompressed.delete()
zos.closeEntry()
}
zos.close()
tempDir.deleteRecursively()
println("APK transformed successfully")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

0
firka/assets/firka.i Normal file
View File

View File

@@ -0,0 +1,55 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:brotli/brotli.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class FirkaBundle extends CachingAssetBundle {
final bool _compressedBundle = !kDebugMode && Platform.isAndroid;
Map<String, dynamic>? index;
Future<Map<String, dynamic>> loadIndex() async {
var indexBrotli = await rootBundle.load("assets/firka.i");
var indexStr = brotli.decodeToString(indexBrotli.buffer.asInt8List());
return Future.value(jsonDecode(indexStr));
}
ByteData decode(Codec<List<int>, List<int>> codec, ByteData data) {
var dec = codec.decode(data.buffer.asInt8List());
var b = ByteData(dec.length);
var l = b.buffer.asInt8List();
for (var i = 0; i < dec.length; i++) {
l[i] = dec[i];
}
return b;
}
@override
Future<ByteData> load(String key) async {
if (!_compressedBundle) {
return rootBundle.load(key);
} else {
index ??= await loadIndex();
final gzip = GZipCodec();
debugPrint("assets/flutter_assets/$key");
switch (index!["assets/flutter_assets/$key"]!) {
case "b": // brotli
return decode(brotli, await rootBundle.load(key));
case "g": // gzip
return decode(gzip, await rootBundle.load(key));
case "r": // raw
return rootBundle.load(key);
default:
throw "Unknown file format: ${index![key]!}";
}
}
}
}

View File

@@ -10,6 +10,7 @@ import 'package:firka/helpers/db/models/timetable_cache_model.dart';
import 'package:firka/helpers/db/models/token_model.dart';
import 'package:firka/helpers/db/widget.dart';
import 'package:firka/helpers/extensions.dart';
import 'package:firka/helpers/firka_bundle.dart';
import 'package:firka/helpers/settings/setting.dart';
import 'package:firka/l10n/app_localizations_hu.dart';
import 'package:firka/ui/model/style.dart';
@@ -180,14 +181,16 @@ class InitializationScreen extends StatelessWidget {
// Handle initialization error
return MaterialApp(
key: ValueKey('errorPage'),
home: Scaffold(
body: Center(
child: Text(
'Error initializing app: ${snapshot.error}',
style: TextStyle(color: Colors.red),
),
),
),
home: DefaultAssetBundle(
bundle: FirkaBundle(),
child: Scaffold(
body: Center(
child: Text(
'Error initializing app: ${snapshot.error}',
style: TextStyle(color: Colors.red),
),
),
)),
);
}
@@ -246,30 +249,39 @@ class InitializationScreen extends StatelessWidget {
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: screen,
home: DefaultAssetBundle(bundle: FirkaBundle(), child: screen),
routes: {
'/login': (context) => LoginScreen(
initData,
key: ValueKey('loginScreen'),
'/login': (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: LoginScreen(
initData,
key: ValueKey('loginScreen'),
),
),
'/debug': (context) => DebugScreen(
initData,
key: ValueKey('debugScreen'),
'/debug': (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: DebugScreen(
initData,
key: ValueKey('debugScreen'),
),
),
},
);
}
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
color: const Color(0xFF7CA021),
)
],
home: DefaultAssetBundle(
bundle: FirkaBundle(),
child: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
color: const Color(0xFF7CA021),
)
],
),
),
),
),

View File

@@ -4,6 +4,7 @@ import 'package:firka/ui/model/style.dart';
import 'package:firka/ui/phone/screens/settings/settings_screen.dart';
import 'package:flutter/material.dart';
import '../../../../helpers/firka_bundle.dart';
import '../../screens/debug/debug_screen.dart';
void showExtrasBottomSheet(BuildContext context, AppInitialization data) {
@@ -44,7 +45,9 @@ void showExtrasBottomSheet(BuildContext context, AppInitialization data) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DebugScreen(data)))
builder: (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: DebugScreen(data))))
},
child: FirkaCard(
left: [Text('Debug screen')],
@@ -57,8 +60,10 @@ void showExtrasBottomSheet(BuildContext context, AppInitialization data) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
SettingsScreen(data, data.settings.items)));
builder: (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: SettingsScreen(
data, data.settings.items))));
},
child: FirkaCard(
left: [Text('Settings')],

View File

@@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../helpers/debug_helper.dart';
import '../../../../helpers/firka_bundle.dart';
import '../../../widget/firka_icon.dart';
class DebugScreen extends StatefulWidget {
@@ -217,7 +218,9 @@ class _DebugScreen extends State<DebugScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LoginScreen(widget.data)));
builder: (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: LoginScreen(widget.data))));
},
child: const Text('wipe users'),
),

View File

@@ -9,6 +9,7 @@ import 'package:firka/ui/widget/firka_icon.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../helpers/firka_bundle.dart';
import '../../../../helpers/settings/setting.dart';
class SettingsScreen extends StatefulWidget {
@@ -52,8 +53,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
));
}
List<Widget> createWidgetTree(Iterable<SettingsItem> items,
SettingsStore settings) {
List<Widget> createWidgetTree(
Iterable<SettingsItem> items, SettingsStore settings) {
var widgets = List<Widget>.empty(growable: true);
for (var item in items) {
@@ -83,7 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
widgets.add(Text(
item.title,
style:
appStyle.fonts.H_14px.apply(color: appStyle.colors.textPrimary),
appStyle.fonts.H_14px.apply(color: appStyle.colors.textPrimary),
));
continue;
@@ -109,8 +110,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
SettingsScreen(widget.data, item.children)));
builder: (context) => DefaultAssetBundle(
bundle: FirkaBundle(),
child: SettingsScreen(widget.data, item.children))));
},
child: FirkaCard(left: cardWidgets),
));
@@ -188,9 +190,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Checkbox(
value: true,
fillColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) {
return appStyle.colors.secondary;
}),
(Set<WidgetState> states) {
return appStyle.colors.secondary;
}),
onChanged: (_) async {
setState(() {
item.activeIndex = i;
@@ -235,10 +237,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
fit: BoxFit.cover),
borderRadius: BorderRadius.all(Radius.circular(16)),
),
width: MediaQuery
.of(context)
.size
.width,
width: MediaQuery.of(context).size.width,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
@@ -247,7 +246,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16.0)),
const BorderRadius.all(Radius.circular(16.0)),
child: Image.asset(
"assets/images/icons/$activeIcon.png",
width: 74,
@@ -281,38 +280,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
GestureDetector(
child: active
? Container(
decoration: BoxDecoration(
color: appStyle.colors.accent,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(12.0)),
child: Image.asset(
"assets/images/icons/$icon.png",
width: 48,
height: 48,
),
),
),
)
decoration: BoxDecoration(
color: appStyle.colors.accent,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(12.0)),
child: Image.asset(
"assets/images/icons/$icon.png",
width: 48,
height: 48,
),
),
),
)
: Container(
decoration: BoxDecoration(
color: appStyle.colors.accent,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16.0)),
child: Image.asset(
"assets/images/icons/$icon.png",
width: 54,
height: 54,
),
),
),
decoration: BoxDecoration(
color: appStyle.colors.accent,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16.0)),
child: Image.asset(
"assets/images/icons/$icon.png",
width: 54,
height: 54,
),
),
),
onTap: () {
if (settingAppIcon) return;
@@ -333,7 +332,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
pWidgets.add(Text(
group,
style:
appStyle.fonts.H_14px.apply(color: appStyle.colors.textPrimary),
appStyle.fonts.H_14px.apply(color: appStyle.colors.textPrimary),
));
pWidgets.add(SizedBox(height: 12));
pWidgets.add(SizedBox(
@@ -347,15 +346,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
widgets.add(SizedBox(
height: MediaQuery
.of(context)
.size
.height / 1.7,
height: MediaQuery.of(context).size.height / 1.7,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: pWidgets,
)),
crossAxisAlignment: CrossAxisAlignment.start,
children: pWidgets,
)),
));
widgets.add(Row(
@@ -421,10 +417,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
backgroundColor: appStyle.colors.background,
body: SafeArea(
child: SizedBox(
height: MediaQuery
.of(context)
.size
.height,
height: MediaQuery.of(context).size.height,
child: Stack(
children: [
Padding(
@@ -452,15 +445,11 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
backgroundColor: Colors.transparent,
barrierColor: appStyle.colors.a15p,
constraints: BoxConstraints(
maxHeight: MediaQuery
.of(context)
.size
.height * 0.13,
maxHeight: MediaQuery.of(context).size.height * 0.13,
),
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, setState) =>
Stack(
builder: (BuildContext context, setState) => Stack(
children: [
Positioned.fill(
child: GestureDetector(
@@ -475,7 +464,7 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
decoration: BoxDecoration(
color: appStyle.colors.card,
borderRadius:
BorderRadius.vertical(top: Radius.circular(16)),
BorderRadius.vertical(top: Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.only(
@@ -484,9 +473,9 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
children: [
Center(
child: Text(
setting.title,
style: appStyle.fonts.B_14R,
)),
setting.title,
style: appStyle.fonts.B_14R,
)),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 0, horizontal: 40),

View File

@@ -59,6 +59,7 @@ dependencies:
flutter_arc_text: ^0.6.0
flutter_svg: ^1.1.6
home_widget: ^0.8.0
brotli: ^0.6.0
dev_dependencies:
flutter_test:
@@ -88,6 +89,7 @@ flutter:
- assets/images/icons/
- assets/images/background.png
- assets/majesticons/
- assets/firka.i
fonts:
- family: Montserrat