diff --git a/firka/android/app/build.gradle.kts b/firka/android/app/build.gradle.kts index f1df283..d4ad021 100644 --- a/firka/android/app/build.gradle.kts +++ b/firka/android/app/build.gradle.kts @@ -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() + val indexReadWriteLock = ReentrantReadWriteLock() + + if (compressionLevel == "Z") { + if (optipng != null) { + val executor = Executors.newFixedThreadPool(coreCount) + val futures = mutableListOf>() + + 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>() - 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") diff --git a/firka/android/app/src/main/ic_lgbtqp-playstore.png b/firka/android/app/src/main/ic_lgbtqp-playstore.png deleted file mode 100644 index b23dd9a..0000000 Binary files a/firka/android/app/src/main/ic_lgbtqp-playstore.png and /dev/null differ diff --git a/firka/android/app/src/main/ic_lgbtqp_f-playstore.png b/firka/android/app/src/main/ic_lgbtqp_f-playstore.png deleted file mode 100644 index 5ee2c9d..0000000 Binary files a/firka/android/app/src/main/ic_lgbtqp_f-playstore.png and /dev/null differ diff --git a/firka/assets/firka.i b/firka/assets/firka.i new file mode 100644 index 0000000..e69de29 diff --git a/firka/lib/helpers/firka_bundle.dart b/firka/lib/helpers/firka_bundle.dart new file mode 100644 index 0000000..7c8ee0d --- /dev/null +++ b/firka/lib/helpers/firka_bundle.dart @@ -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? index; + + Future> 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> 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 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]!}"; + } + } + } +} diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 2ab571a..904da25 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -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), + ) + ], + ), ), ), ), diff --git a/firka/lib/ui/phone/pages/extras/extras.dart b/firka/lib/ui/phone/pages/extras/extras.dart index a010157..a41ab43 100644 --- a/firka/lib/ui/phone/pages/extras/extras.dart +++ b/firka/lib/ui/phone/pages/extras/extras.dart @@ -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')], diff --git a/firka/lib/ui/phone/screens/debug/debug_screen.dart b/firka/lib/ui/phone/screens/debug/debug_screen.dart index bd8eeeb..83a6c3b 100644 --- a/firka/lib/ui/phone/screens/debug/debug_screen.dart +++ b/firka/lib/ui/phone/screens/debug/debug_screen.dart @@ -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 { Navigator.push( context, MaterialPageRoute( - builder: (context) => LoginScreen(widget.data))); + builder: (context) => DefaultAssetBundle( + bundle: FirkaBundle(), + child: LoginScreen(widget.data)))); }, child: const Text('wipe users'), ), diff --git a/firka/lib/ui/phone/screens/settings/settings_screen.dart b/firka/lib/ui/phone/screens/settings/settings_screen.dart index 6fbc3ac..f731b4a 100644 --- a/firka/lib/ui/phone/screens/settings/settings_screen.dart +++ b/firka/lib/ui/phone/screens/settings/settings_screen.dart @@ -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 { )); } - List createWidgetTree(Iterable items, - SettingsStore settings) { + List createWidgetTree( + Iterable items, SettingsStore settings) { var widgets = List.empty(growable: true); for (var item in items) { @@ -83,7 +84,7 @@ class _SettingsScreenState extends State { 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 { 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 { Checkbox( value: true, fillColor: WidgetStateProperty.resolveWith( - (Set states) { - return appStyle.colors.secondary; - }), + (Set states) { + return appStyle.colors.secondary; + }), onChanged: (_) async { setState(() { item.activeIndex = i; @@ -235,10 +237,7 @@ class _SettingsScreenState extends State { 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 { 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 { 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 { 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 { } 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 { 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), diff --git a/firka/pubspec.yaml b/firka/pubspec.yaml index 00b644d..0264d96 100644 --- a/firka/pubspec.yaml +++ b/firka/pubspec.yaml @@ -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