diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e808aee..63a3ef98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,13 @@ on: branches: [master, main] paths: - 'app/**' + - 'packages/**' - '.github/workflows/**' pull_request: branches: [master, main] paths: - 'app/**' + - 'packages/**' - '.github/workflows/**' workflow_dispatch: diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 00000000..85f5d66d --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,21 @@ +name: Release configuration checks + +on: + pull_request: + paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'packages/**'] + push: + branches: [master] + paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'packages/**'] + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tools/tests -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 953c340c..16233584 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,83 +2,115 @@ name: Release on: push: - tags: - - 'v*' + branches: [master] + paths: ['app/pubspec.yaml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false env: FLUTTER_VERSION: '3.41.2' - PAPYRUS_API_BASE_URL: ${{ vars.PAPYRUS_API_BASE_URL }} - POWERSYNC_SERVICE_URL: ${{ vars.POWERSYNC_SERVICE_URL }} jobs: version: - name: Extract version + if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest outputs: + release: ${{ steps.version.outputs.release }} version: ${{ steps.version.outputs.version }} build_number: ${{ steps.version.outputs.build_number }} + tag: ${{ steps.version.outputs.tag }} steps: - - name: Extract version from tag + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tools/tests -v + - name: Check committed version id: version + env: + BEFORE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} run: | - TAG=${GITHUB_REF#refs/tags/v} - echo "version=$TAG" >> $GITHUB_OUTPUT - echo "build_number=${{ github.run_number }}" >> $GITHUB_OUTPUT + if [ "$EVENT_NAME" = workflow_dispatch ]; then + python tools/release_gate.py client --manual + else + python tools/release_gate.py client --base "$BEFORE" + fi build-android: - name: Build Android needs: version + if: needs.version.outputs.release == 'true' + environment: release runs-on: ubuntu-latest + env: + PAPYRUS_API_BASE_URL: ${{ vars.PAPYRUS_API_BASE_URL }} + POWERSYNC_SERVICE_URL: ${{ vars.POWERSYNC_SERVICE_URL }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} defaults: run: working-directory: app steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 + - name: Set temporary keystore path + run: printf 'ANDROID_KEYSTORE_PATH=%s/upload.jks\n' "$RUNNER_TEMP" >> "$GITHUB_ENV" + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: - distribution: 'temurin' + distribution: temurin java-version: '17' - cache: 'gradle' - - - name: Setup Flutter - uses: subosito/flutter-action@v2 + cache: gradle + - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - - - name: Validate release configuration + - name: Validate endpoints and restore upload key + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | - test -n "${{ env.PAPYRUS_API_BASE_URL }}" || (echo "PAPYRUS_API_BASE_URL repository variable is required" && exit 1) - test -n "${{ env.POWERSYNC_SERVICE_URL }}" || (echo "POWERSYNC_SERVICE_URL repository variable is required" && exit 1) - - - name: Install dependencies - run: flutter pub get - - - name: Build APK + python ../tools/release_gate.py client --endpoints + test -n "$KEYSTORE_BASE64" && test -n "$ANDROID_KEYSTORE_PASSWORD" && test -n "$ANDROID_KEY_ALIAS" && test -n "$ANDROID_KEY_PASSWORD" || { echo "Configure the Android upload-key secrets in the release environment"; exit 1; } + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$ANDROID_KEYSTORE_PATH" + chmod 600 "$ANDROID_KEYSTORE_PATH" + - name: Install locked dependencies + run: flutter pub get --enforce-lockfile + - name: Build signed Google Play bundle run: | - flutter build apk --release \ - --dart-define=PAPYRUS_API_BASE_URL="${{ env.PAPYRUS_API_BASE_URL }}" \ - --dart-define=POWERSYNC_SERVICE_URL="${{ env.POWERSYNC_SERVICE_URL }}" \ - --build-name=${{ needs.version.outputs.version }} \ - --build-number=${{ needs.version.outputs.build_number }} - - - name: Rename APK + flutter build appbundle --release \ + --dart-define=PAPYRUS_API_BASE_URL="$PAPYRUS_API_BASE_URL" \ + --dart-define=POWERSYNC_SERVICE_URL="$POWERSYNC_SERVICE_URL" + - name: Check 16 KB native and bundle alignment run: | - mv build/app/outputs/flutter-apk/app-release.apk \ - papyrus-android-v${{ needs.version.outputs.version }}.apk - - - name: Upload artifact - uses: actions/upload-artifact@v4 + python ../tools/check_android_bundle.py build/app/outputs/bundle/release/app-release.aab + curl --fail --location --retry 3 https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar --output "$RUNNER_TEMP/bundletool.jar" + printf 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 %s/bundletool.jar\n' "$RUNNER_TEMP" | sha256sum --check + java -jar "$RUNNER_TEMP/bundletool.jar" dump config --bundle=build/app/outputs/bundle/release/app-release.aab > "$RUNNER_TEMP/bundle-config.json" + python -c 'import json, os; from pathlib import Path; config = json.loads((Path(os.environ["RUNNER_TEMP"]) / "bundle-config.json").read_text()); assert config["optimizations"]["uncompressNativeLibraries"]["alignment"] == "PAGE_ALIGNMENT_16K", "Bundle must request 16 KB APK alignment"' + - uses: actions/upload-artifact@v4 with: name: android-release - path: app/papyrus-android-v${{ needs.version.outputs.version }}.apk + path: app/build/app/outputs/bundle/release/app-release.aab + if-no-files-found: error + - name: Remove upload key + if: always() + run: rm -f "$ANDROID_KEYSTORE_PATH" build-web: name: Build Web needs: version + if: needs.version.outputs.release == 'true' + environment: release + env: + PAPYRUS_API_BASE_URL: ${{ vars.PAPYRUS_API_BASE_URL }} + POWERSYNC_SERVICE_URL: ${{ vars.POWERSYNC_SERVICE_URL }} runs-on: ubuntu-latest defaults: run: @@ -94,18 +126,16 @@ jobs: cache: true - name: Validate release configuration - run: | - test -n "${{ env.PAPYRUS_API_BASE_URL }}" || (echo "PAPYRUS_API_BASE_URL repository variable is required" && exit 1) - test -n "${{ env.POWERSYNC_SERVICE_URL }}" || (echo "POWERSYNC_SERVICE_URL repository variable is required" && exit 1) + run: python ../tools/release_gate.py client --endpoints - name: Install dependencies - run: flutter pub get + run: flutter pub get --enforce-lockfile - name: Build web run: | flutter build web --release \ - --dart-define=PAPYRUS_API_BASE_URL="${{ env.PAPYRUS_API_BASE_URL }}" \ - --dart-define=POWERSYNC_SERVICE_URL="${{ env.POWERSYNC_SERVICE_URL }}" \ + --dart-define=PAPYRUS_API_BASE_URL="${PAPYRUS_API_BASE_URL}" \ + --dart-define=POWERSYNC_SERVICE_URL="${POWERSYNC_SERVICE_URL}" \ --build-name=${{ needs.version.outputs.version }} \ --build-number=${{ needs.version.outputs.build_number }} @@ -123,6 +153,11 @@ jobs: build-linux: name: Build Linux needs: version + if: needs.version.outputs.release == 'true' + environment: release + env: + PAPYRUS_API_BASE_URL: ${{ vars.PAPYRUS_API_BASE_URL }} + POWERSYNC_SERVICE_URL: ${{ vars.POWERSYNC_SERVICE_URL }} runs-on: ubuntu-latest defaults: run: @@ -143,18 +178,16 @@ jobs: cache: true - name: Validate release configuration - run: | - test -n "${{ env.PAPYRUS_API_BASE_URL }}" || (echo "PAPYRUS_API_BASE_URL repository variable is required" && exit 1) - test -n "${{ env.POWERSYNC_SERVICE_URL }}" || (echo "POWERSYNC_SERVICE_URL repository variable is required" && exit 1) + run: python ../tools/release_gate.py client --endpoints - name: Install dependencies - run: flutter pub get + run: flutter pub get --enforce-lockfile - name: Build Linux run: | flutter build linux --release \ - --dart-define=PAPYRUS_API_BASE_URL="${{ env.PAPYRUS_API_BASE_URL }}" \ - --dart-define=POWERSYNC_SERVICE_URL="${{ env.POWERSYNC_SERVICE_URL }}" \ + --dart-define=PAPYRUS_API_BASE_URL="${PAPYRUS_API_BASE_URL}" \ + --dart-define=POWERSYNC_SERVICE_URL="${POWERSYNC_SERVICE_URL}" \ --build-name=${{ needs.version.outputs.version }} \ --build-number=${{ needs.version.outputs.build_number }} @@ -172,6 +205,11 @@ jobs: build-windows: name: Build Windows needs: version + if: needs.version.outputs.release == 'true' + environment: release + env: + PAPYRUS_API_BASE_URL: ${{ vars.PAPYRUS_API_BASE_URL }} + POWERSYNC_SERVICE_URL: ${{ vars.POWERSYNC_SERVICE_URL }} runs-on: windows-latest defaults: run: @@ -187,18 +225,16 @@ jobs: cache: true - name: Validate release configuration - run: | - if (-not "${{ env.PAPYRUS_API_BASE_URL }}") { throw "PAPYRUS_API_BASE_URL repository variable is required" } - if (-not "${{ env.POWERSYNC_SERVICE_URL }}") { throw "POWERSYNC_SERVICE_URL repository variable is required" } + run: python ../tools/release_gate.py client --endpoints - name: Install dependencies - run: flutter pub get + run: flutter pub get --enforce-lockfile - name: Build Windows run: | flutter build windows --release ` - --dart-define=PAPYRUS_API_BASE_URL="${{ env.PAPYRUS_API_BASE_URL }}" ` - --dart-define=POWERSYNC_SERVICE_URL="${{ env.POWERSYNC_SERVICE_URL }}" ` + --dart-define=PAPYRUS_API_BASE_URL="$env:PAPYRUS_API_BASE_URL" ` + --dart-define=POWERSYNC_SERVICE_URL="$env:POWERSYNC_SERVICE_URL" ` --build-name=${{ needs.version.outputs.version }} ` --build-number=${{ needs.version.outputs.build_number }} @@ -213,29 +249,19 @@ jobs: path: app/papyrus-windows-v${{ needs.version.outputs.version }}.zip release: - name: Create GitHub Release needs: [version, build-android, build-web, build-linux, build-windows] + if: needs.version.outputs.release == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: path: artifacts - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + - uses: softprops/action-gh-release@v2 with: - name: Papyrus v${{ needs.version.outputs.version }} + tag_name: ${{ needs.version.outputs.tag }} + target_commitish: ${{ github.sha }} + name: Papyrus ${{ needs.version.outputs.tag }} generate_release_notes: true - files: | - artifacts/android-release/* - artifacts/web-release/* - artifacts/linux-release/* - artifacts/windows-release/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + files: artifacts/**/* diff --git a/.gitignore b/.gitignore index fdf3e2a6..9f009562 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ test/data/ .idea/ # Developer-only reader overrides; release dependencies stay Git-pinned. app/pubspec_overrides.yaml +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 5efacd69..85e52c27 100644 --- a/README.md +++ b/README.md @@ -188,3 +188,9 @@ Keep actionable bug reports and technical decisions in GitHub issues and pull re | [website](https://github.com/PapyrusReader/website) | Landing page | | [docs](https://github.com/PapyrusReader/docs) | Documentation | | [papyrus](https://github.com/PapyrusReader/papyrus) | Development workspace | + +## Google Play testing builds + +See [the release guide](docs/RELEASING.md) for signed Android App Bundles, +version-triggered GitHub builds, required endpoint/signing settings and the first +internal testing upload. diff --git a/app/android/.gitignore b/app/android/.gitignore index be3943c9..ae55d439 100644 --- a/app/android/.gitignore +++ b/app/android/.gitignore @@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +/build/ diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index f151ef1a..2e947359 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -1,9 +1,31 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") id("dev.flutter.flutter-gradle-plugin") } +val keyProperties = Properties() +val keyPropertiesFile = rootProject.file("key.properties") +if (keyPropertiesFile.exists()) { + keyPropertiesFile.inputStream().use { keyProperties.load(it) } +} +fun signingValue(environment: String, property: String): String? = + System.getenv(environment)?.takeIf { it.isNotBlank() } + ?: keyProperties.getProperty(property)?.takeIf { it.isNotBlank() } +val uploadStore = signingValue("ANDROID_KEYSTORE_PATH", "storeFile") +val uploadStorePassword = signingValue("ANDROID_KEYSTORE_PASSWORD", "storePassword") +val uploadAlias = signingValue("ANDROID_KEY_ALIAS", "keyAlias") +val uploadKeyPassword = signingValue("ANDROID_KEY_PASSWORD", "keyPassword") +val hasUploadKey = listOf(uploadStore, uploadStorePassword, uploadAlias, uploadKeyPassword).all { it != null } + +gradle.taskGraph.whenReady { + if (allTasks.any { it.project == project && it.name.contains("Release") } && !hasUploadKey) { + throw GradleException("Release builds require an upload keystore. See docs/RELEASING.md; debug signing is never used for releases.") + } +} + android { namespace = "com.papyrus.papyrus" compileSdk = flutter.compileSdkVersion @@ -19,21 +41,27 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.papyrus.papyrus" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (hasUploadKey) { + create("upload") { + storeFile = rootProject.file(uploadStore!!) + storePassword = uploadStorePassword + keyAlias = uploadAlias + keyPassword = uploadKeyPassword + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + if (hasUploadKey) signingConfig = signingConfigs.getByName("upload") } } } @@ -43,4 +71,5 @@ flutter { } dependencies { -} \ No newline at end of file + implementation("org.slf4j:slf4j-nop:1.7.36") +} diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 0c05a42c..5a63d016 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ + + extract_file(input_file_path,destination_path, {password=""}) async { + // Extraction may fail, so we use a try/catch PlatformException. + try { + await UnrarFile.extract_rar(input_file_path, destination_path, password: password); + } catch(e) { + print("extraction failed $e"); + } + return; + } + +``` + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + + +## Contributing +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + + +## License +[Apache-2.0 License](https://github.com/syedecryptr/unrar_file/blob/master/LICENSE) diff --git a/packages/unrar_file/android/build.gradle b/packages/unrar_file/android/build.gradle new file mode 100644 index 00000000..afbadc71 --- /dev/null +++ b/packages/unrar_file/android/build.gradle @@ -0,0 +1,22 @@ +group 'com.syed.unrar_file' +version '1.0' + +apply plugin: 'com.android.library' + +android { + namespace 'com.syed.unrar_file' + compileSdk 36 + + defaultConfig { + minSdkVersion 24 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + implementation 'com.github.junrar:junrar:7.4.0' +} diff --git a/packages/unrar_file/android/src/main/AndroidManifest.xml b/packages/unrar_file/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..94cbbcfc --- /dev/null +++ b/packages/unrar_file/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/unrar_file/android/src/main/java/com/syed/unrar_file/UnrarFilePlugin.java b/packages/unrar_file/android/src/main/java/com/syed/unrar_file/UnrarFilePlugin.java new file mode 100644 index 00000000..99d45d10 --- /dev/null +++ b/packages/unrar_file/android/src/main/java/com/syed/unrar_file/UnrarFilePlugin.java @@ -0,0 +1,74 @@ +package com.syed.unrar_file; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.github.junrar.Junrar; +import com.github.junrar.exception.RarException; +import com.github.junrar.exception.UnsupportedRarV5Exception; + +import java.io.IOException; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +/** UnrarFilePlugin */ +public class UnrarFilePlugin implements FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private MethodChannel channel; + + public static boolean isNullOrEmpty(String str) { + if(str != null && !str.isEmpty()) + return false; + return true; + } + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "unrar_file"); + channel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + if (call.method.equals("extractRAR")) { + final String file_path = call.argument("file_path"); + final String destination_path = call.argument("destination_path"); + final String password = call.argument("password"); +// result.success("extraction done"); + try { + if(isNullOrEmpty(password)) { + Junrar.extract(file_path, destination_path); + } + else{ + Junrar.extract(file_path, destination_path, password); + } + result.success("Extraction Success"); + + } + catch (UnsupportedRarV5Exception e ){ + + result.error("extractionRAR5Error", e.toString(), null); + + } + catch (IOException | RarException e){ + + result.error("extractionError", e.toString(), null); + } + } else { + result.notImplemented(); + } + } + + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + } +} diff --git a/packages/unrar_file/ios/.gitignore b/packages/unrar_file/ios/.gitignore new file mode 100644 index 00000000..aa479fd3 --- /dev/null +++ b/packages/unrar_file/ios/.gitignore @@ -0,0 +1,37 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/unrar_file/ios/Assets/.gitkeep b/packages/unrar_file/ios/Assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/unrar_file/ios/Classes/UnrarFilePlugin.h b/packages/unrar_file/ios/Classes/UnrarFilePlugin.h new file mode 100644 index 00000000..31b2b779 --- /dev/null +++ b/packages/unrar_file/ios/Classes/UnrarFilePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface UnrarFilePlugin : NSObject +@end diff --git a/packages/unrar_file/ios/Classes/UnrarFilePlugin.m b/packages/unrar_file/ios/Classes/UnrarFilePlugin.m new file mode 100644 index 00000000..b9b1be9d --- /dev/null +++ b/packages/unrar_file/ios/Classes/UnrarFilePlugin.m @@ -0,0 +1,39 @@ +#import "UnrarFilePlugin.h" +@import UnrarKit; + +static inline NSString* NSStringFromBOOL(BOOL aBool) { + return aBool? @"SUCCESS" : @"FAILURE"; +} + +@implementation UnrarFilePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + FlutterMethodChannel* channel = [FlutterMethodChannel + methodChannelWithName:@"unrar_file" + binaryMessenger:[registrar messenger]]; + UnrarFilePlugin* instance = [[UnrarFilePlugin alloc] init]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([@"extractRAR" isEqualToString:call.method]) { + NSString* file_path = call.arguments[@"file_path"]; + NSString* destination_path = call.arguments[@"destination_path"]; + NSString* password = call.arguments[@"password"]; + NSError *archiveError = nil; + URKArchive *archive = [[URKArchive alloc] initWithPath:file_path + error:&archiveError]; + NSError *error = nil; + BOOL extractFilesSuccessful; + if (archive.isPasswordProtected && password.length!=0) { + archive.password = password; + } + extractFilesSuccessful = [archive extractFilesTo:destination_path overwrite:NO + error:&error]; + + result(NSStringFromBOOL(extractFilesSuccessful)); + } else { + result(FlutterMethodNotImplemented); + } +} + +@end diff --git a/packages/unrar_file/ios/unrar_file.podspec b/packages/unrar_file/ios/unrar_file.podspec new file mode 100644 index 00000000..4ef09192 --- /dev/null +++ b/packages/unrar_file/ios/unrar_file.podspec @@ -0,0 +1,27 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'unrar_file' + s.version = '0.0.1' + s.summary = 'Archive a rar file using junrar android and Unrarkit in ios code on github.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'https://github.com/syedecryptr/unrar_file' + s.license = { :file => '../LICENSE' } + s.author = { 'syedecryptr' => 'syedecryptr@gmail.com' } + s.source = { :path => '.' } + + + s.source_files = [ + 'Classes/**/*', + ] + + s.dependency 'Flutter' + + s.dependency 'UnrarKit' + s.ios.deployment_target = '9.0' + + +end diff --git a/packages/unrar_file/lib/src/rarFile.dart b/packages/unrar_file/lib/src/rarFile.dart new file mode 100644 index 00000000..c364d4ef --- /dev/null +++ b/packages/unrar_file/lib/src/rarFile.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'rar_decoder.dart'; + +class RarFile { + String? name; + String? path; + late List hierarchy; + Uint8List? content; + Uint8List? extra; + int? _flags; + int? _compressionInfo; + String? parent; + List children = []; + + int get compressionVersion => _compressionInfo! & 0x003f; + + bool get solidFlag => (_compressionInfo! & 0x0040) == 1; + + int get compressionMethod => _compressionInfo! & 0x0380; + + bool get isDirectory => (_flags! & 1) == 1; + + RarFile(FILE_SERVICE_BLOCK block) { + hierarchy = utf8.decode(block.NAME!).split('/'); + path = utf8.decode(block.NAME!); + name = hierarchy.last; + parent = (hierarchy.length > 1) ? hierarchy.sublist(0, hierarchy.length - 1).join('/') : null; + extra = block.EXTRA_AREA; + content = block.DATA_AREA; + _flags = block.FILE_FLAGS; + _compressionInfo = block.COMPRESSION_INFO; + } +} diff --git a/packages/unrar_file/lib/src/rar_decoder.dart b/packages/unrar_file/lib/src/rar_decoder.dart new file mode 100644 index 00000000..2c4eeb04 --- /dev/null +++ b/packages/unrar_file/lib/src/rar_decoder.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'rarFile.dart'; +import 'vint.dart'; + +class RAR5 { + static const SIGNATURE = 0x0001071A21726152; //[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00]; + MAIN_ARCHIVE_HEADER? MAIN_HEAD; + ARCHIVE_ENCRYPTION? ARCHIVE_ENCRYPTION_BLOCK; + List SERVICE_BLOCKS = []; + List _files = []; + END_OF_ARCHIVE? END_OF_ARCHIVE_BLOCK; + + List hierarchy = []; + + String get comment => utf8.decode(SERVICE_BLOCKS.first.DATA_AREA!); + + get files => _files; + + RAR5(path) { + var file = File(path); + var randomAccessFile = file.openSync(mode: FileMode.read); + + var signature = randomAccessFile.readSync(8).merge(true); + if (signature != SIGNATURE) { + throw RAR5Exception('Wrong signature'); + } + + // assert(signature == [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00].merge(true), + // '$path is not a .rar file version 4.'); + + while (randomAccessFile.positionSync() < randomAccessFile.lengthSync()) { + var header = Header(randomAccessFile); + + // Check HEAD_TYPE and create a block object accordingly + switch (header.HEAD_TYPE) { + case 1: + MAIN_HEAD = MAIN_ARCHIVE_HEADER(randomAccessFile, header); + break; + case 2: + FILE_SERVICE_BLOCK block = FILE_SERVICE_BLOCK(randomAccessFile, header); + RarFile file = RarFile(block); + _files.add(file); + break; + case 3: + SERVICE_BLOCKS.add(FILE_SERVICE_BLOCK(randomAccessFile, header)); + break; + case 4: + ARCHIVE_ENCRYPTION_BLOCK = ARCHIVE_ENCRYPTION(randomAccessFile, header); + break; + case 5: + END_OF_ARCHIVE_BLOCK = END_OF_ARCHIVE(randomAccessFile, header); + break; + default: + throw Exception('Unknown block'); + } + } + _createHierarchy(); + randomAccessFile.closeSync(); + } + + /// Joins files inside a directory to their parent directory + void _createHierarchy() { + List removed = []; + for (RarFile file in _files) { + if (removed.contains(file)) { + continue; + } + if (file.parent != null) { + int i = _files.indexWhere((f) => f.path == file.parent); + _files[i].children.add(file); + removed.add(file); + } + } + for (RarFile r in removed) { + _files.remove(r); + } + } + + /// Prints file hierarchy inside the archive + void showFiles([List? listOfNodes, int? level]) { + if (listOfNodes == null) { + listOfNodes = _files; + } + + if (level == null) { + level = 0; + } + for (var file in listOfNodes) { + print('${'\t' * level}${file.name}'); + + if (!file.children.isEmpty) { + showFiles(file.children, level + 1); + } + } + } +} + +class Header { + int? HEAD_CRC; // 4 bytes + int? HEAD_TYPE; // vint + late int HEAD_FLAGS; // vint + int? HEAD_SIZE; // vint + + Header(RandomAccessFile file) { + HEAD_CRC = file.readSync(4).merge(true); + HEAD_SIZE = VINT.fromFile(file).result; + HEAD_TYPE = VINT.fromFile(file).result; + HEAD_FLAGS = VINT.fromFile(file).result; + } +} + +abstract class Block { + Header header; + + Block(this.header); +} + +class MAIN_ARCHIVE_HEADER extends Block { + int? EXTRA_AREA_SIZE, ARCHIVE_FLAGS, VOLUME_NUMBER; + var EXTRA_AREA; + + MAIN_ARCHIVE_HEADER(RandomAccessFile file, Header header) : super(header) { + EXTRA_AREA_SIZE = (super.header.HEAD_FLAGS & 0x0001 != 0) ? VINT.fromFile(file).result : null; + ARCHIVE_FLAGS = VINT.fromFile(file).result; + VOLUME_NUMBER = (super.header.HEAD_FLAGS & 0x0002 != 0) ? VINT.fromFile(file).result : null; + EXTRA_AREA = (super.header.HEAD_FLAGS & 0x0001 != 0) ? file.readSync(EXTRA_AREA_SIZE!) : null; + } +} + +class FILE_SERVICE_BLOCK extends Block { + int? EXTRA_AREA_SIZE, + DATA_SIZE, + FILE_FLAGS, + UNP_SIZE, + ATTRIBUTES, + MTIME, + DATA_CRC, + COMPRESSION_INFO, + HOST_OS, + NAME_SIZE; + + Uint8List? EXTRA_AREA; + + Uint8List? DATA_AREA, NAME; + + FILE_SERVICE_BLOCK(RandomAccessFile file, Header header) : super(header) { + EXTRA_AREA_SIZE = (super.header.HEAD_FLAGS & 0x0001 != 0) ? VINT.fromFile(file).result : null; + DATA_SIZE = (super.header.HEAD_FLAGS & 0x0002 != 0) ? VINT.fromFile(file).result : null; + FILE_FLAGS = VINT.fromFile(file).result; + UNP_SIZE = VINT.fromFile(file).result; + ATTRIBUTES = VINT.fromFile(file).result; + MTIME = (FILE_FLAGS! & 0x0002 != 0) ? file.readSync(4).merge(true) : null; + DATA_CRC = (FILE_FLAGS! & 0x0004 != 0) ? file.readSync(4).merge(true) : null; + COMPRESSION_INFO = VINT.fromFile(file).result; + HOST_OS = VINT.fromFile(file).result; + NAME_SIZE = VINT.fromFile(file).result; + NAME = file.readSync(NAME_SIZE!); + EXTRA_AREA = (super.header.HEAD_FLAGS & 0x0001 != 0) ? file.readSync(EXTRA_AREA_SIZE!) : null; + DATA_AREA = (super.header.HEAD_FLAGS & 0x0002 != 0) ? file.readSync(DATA_SIZE!) : null; + } +} + +class END_OF_ARCHIVE extends Block { + int? END_OF_ARCHIVE_FLAGS; + + END_OF_ARCHIVE(RandomAccessFile file, Header header) : super(header) { + END_OF_ARCHIVE_FLAGS = VINT.fromFile(file).result; + } +} + +class ARCHIVE_ENCRYPTION extends Block { + int? ENCYRPTION_VERSION; // vint + int? ENCYRPTION_FLAGS; // vint + int? KDF_COUNT; // 1 byte + int? SALT; // 16 bytes + int? CHECK_VALUE; // 12 bytes + + ARCHIVE_ENCRYPTION(RandomAccessFile file, Header header) : super(header) { + ENCYRPTION_VERSION = VINT.fromFile(file).result; + ENCYRPTION_FLAGS = VINT.fromFile(file).result; + KDF_COUNT = file.readSync(1).merge(true); + SALT = file.readSync(16).merge(true); + CHECK_VALUE = file.readSync(12).merge(true); + } +} + +class RAR5Exception implements Exception { + String cause; + + RAR5Exception(this.cause); +} diff --git a/packages/unrar_file/lib/src/records.dart b/packages/unrar_file/lib/src/records.dart new file mode 100644 index 00000000..a15e4954 --- /dev/null +++ b/packages/unrar_file/lib/src/records.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; +import 'vint.dart'; + +class Record { + int? size; + int? type; + + Record(Uint8List list) { + size = VINT.fromList(list).result; + type = VINT.fromList(list).result; + } +} + +class FileEncryption { + Record header; + int? version; // vint 0 + int? flags; // vint 3 + int? kdf_count; // 1 byte 15 + int? salt; // 16 bytes 155, 174, 90, 241, 142, 25, 221, 158, 100, 169, 176, 200, 60, 216, 77, 163 + int? iv; // 16 bytes 245, 65, 228, 101, 246, 0, 154, 224, 32, 47, 115, 92, 207, 125, 112, 134 + int? check_value; // 12 bytes 99, 133, 127, 218, 35, 87, 250, 141, 50, 57, 103, 183 + + FileEncryption(Uint8List extra, this.header) { + version = VINT.fromList(extra).result; + flags = VINT.fromList(extra).result; + } +} diff --git a/packages/unrar_file/lib/src/vint.dart b/packages/unrar_file/lib/src/vint.dart new file mode 100644 index 00000000..1d9d1931 --- /dev/null +++ b/packages/unrar_file/lib/src/vint.dart @@ -0,0 +1,45 @@ +import 'dart:io'; +import 'dart:typed_data'; + +class VINT { + final List _ints = []; + + int get result => Uint8List.fromList(_ints).merge(true); + + VINT.fromFile(RandomAccessFile file) { + bool continuationFlag = false; + do { + // Read byte + int byte = file.readByteSync(); + + // Clear the most significant bit [continuation_flag] and add to list of integers + _ints.add(byte & ~(0x80)); + + // Get the most significant bit (continuation_flag) + continuationFlag = (byte & 0x80 != 0) ? true : false; + } while (continuationFlag); + } + + VINT.fromList(Uint8List list) { + int index = 0; + bool continuationFlag = false; + do { + // Read byte + int byte = list[index++]; + + // Clear the most significant bit [continuation_flag] and add to list of integers + _ints.add(byte & ~(0x80)); + + // Get the most significant bit (continuation_flag) + continuationFlag = (byte & 0x80 != 0) ? true : false; + } while (continuationFlag); + list.removeRange(0, index); + } +} + +extension Bytes on List { + int merge([bool littleEndian = false]) { + return Uint8List.fromList(littleEndian ? this.reversed.toList() as List : this as List) + .reduce((value, newElement) => newElement | (value << 8)); + } +} diff --git a/packages/unrar_file/lib/unrar_file.dart b/packages/unrar_file/lib/unrar_file.dart new file mode 100644 index 00000000..2fd7023e --- /dev/null +++ b/packages/unrar_file/lib/unrar_file.dart @@ -0,0 +1,37 @@ + +import 'dart:async'; +import 'package:unrar_file/src/rarFile.dart'; +import 'package:unrar_file/src/rar_decoder.dart'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart'; +class UnrarFile { + static const MethodChannel _channel = + const MethodChannel('unrar_file'); + + static Future extract_rar(file_path, destination_path, {password=""}) async { + try { + var result = await _channel.invokeMethod('extractRAR', {"file_path": file_path, "destination_path": destination_path, "password":password}); + return result; + } + on PlatformException catch(e){ + if(e.code == "extractionRAR5Error"){ + try { + var rar_file = RAR5(file_path); + var files = rar_file.files; + for (RarFile file in files) { + var file_to_save = File(join(destination_path, file.name)); + file_to_save.writeAsBytesSync(file.content!); + } + return "Extraction Success"; + } + catch(e){ + throw e.toString(); + } + } + else{ + throw e.message!; + } + } + } +} diff --git a/packages/unrar_file/pubspec.yaml b/packages/unrar_file/pubspec.yaml new file mode 100644 index 00000000..52712f58 --- /dev/null +++ b/packages/unrar_file/pubspec.yaml @@ -0,0 +1,23 @@ +name: unrar_file +description: Vendored unrar_file with Android build compatibility fixes. +version: 1.1.0 +publish_to: none +homepage: https://github.com/syedecryptr/unrar_file + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.19.0' + +dependencies: + flutter: + sdk: flutter + path: ^1.8.0 + +flutter: + plugin: + platforms: + android: + package: com.syed.unrar_file + pluginClass: UnrarFilePlugin + ios: + pluginClass: UnrarFilePlugin diff --git a/tools/check_android_bundle.py b/tools/check_android_bundle.py new file mode 100644 index 00000000..4497b1ae --- /dev/null +++ b/tools/check_android_bundle.py @@ -0,0 +1,35 @@ +"""Reject 64-bit native ELF libraries that cannot load on Android 16 KB pages.""" + +import struct +import sys +import zipfile +from pathlib import Path + + +def check_elf(data: bytes, name: str) -> None: + if data[:5] != b"\x7fELF\x02": + raise ValueError(f"{name}: expected a 64-bit ELF library") + order = "<" if data[5] == 1 else ">" + header = struct.unpack_from(order + "16sHHIQQQIHHHHHH", data) + offset, size, count = header[5], header[9], header[10] + for index in range(count): + segment = struct.unpack_from(order + "IIQQQQQQ", data, offset + size * index) + kind, _, file_offset, address, _, _, _, alignment = segment + if kind == 1 and (alignment < 16384 or file_offset % 16384 != address % 16384): + raise ValueError(f"{name}: LOAD segment is not 16 KB aligned (alignment={alignment})") + + +def check(path: Path) -> None: + count = 0 + with zipfile.ZipFile(path) as bundle: + for name in bundle.namelist(): + if name.endswith(".so") and ("/arm64-v8a/" in name or "/x86_64/" in name): + check_elf(bundle.read(name), name) + count += 1 + if count == 0: + raise ValueError("No 64-bit native libraries found in the bundle") + print(f"Verified 16 KB ELF alignment of {count} native libraries. Device and APK packaging checks remain required.") + + +if __name__ == "__main__": + check(Path(sys.argv[1])) diff --git a/tools/release_gate.py b/tools/release_gate.py new file mode 100644 index 00000000..037abf3f --- /dev/null +++ b/tools/release_gate.py @@ -0,0 +1,99 @@ +"""Decide releases from committed versions, not file changes or workflow counters.""" + +import argparse +import ipaddress +import os +import re +import subprocess +import tomllib +from collections.abc import Mapping +from pathlib import Path +from urllib.parse import urlsplit + + +def parse(text: str, component: str) -> tuple[str, int]: + if component == "client": + match = re.search(r"^version: ([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)\s*$", text, re.M) + if not match: + raise ValueError("pubspec version must be MAJOR.MINOR.PATCH+BUILD") + version, number = match.groups() + if not 0 < int(number) <= 2100000000: + raise ValueError("Android build number must be between 1 and 2100000000") + return version, int(number) + version = str(tomllib.loads(text)["project"]["version"]) + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("Server version must be MAJOR.MINOR.PATCH") + return version, 0 + + +def decide(current: tuple[str, int], previous: tuple[str, int], component: str) -> bool: + if current == previous: + return False + if tuple(map(int, current[0].split("."))) < tuple(map(int, previous[0].split("."))): + raise ValueError("Release version cannot decrease") + if component == "client" and current[1] <= previous[1]: + raise ValueError("Every Android release must increase the committed build number") + return True + + +def validate_endpoints(environ: Mapping[str, str]) -> None: + for key in ("PAPYRUS_API_BASE_URL", "POWERSYNC_SERVICE_URL"): + value = environ.get(key, "") + uri = urlsplit(value) + host = uri.hostname or "" + if uri.scheme != "https" or not host or uri.username or uri.password or uri.query or uri.fragment: + raise ValueError(f"{key} must be a public HTTPS base URL without credentials, query or fragment") + if uri.path not in ("", "/"): + raise ValueError(f"{key} must be the origin URL; the client adds /v1 itself") + if ( + host == "localhost" + or host.endswith((".localhost", ".local", ".invalid", ".test", ".example.com")) + or host == "example.com" + ): + raise ValueError(f"{key} must not point to a development host") + try: + address = ipaddress.ip_address(host) + except ValueError: + continue + if not address.is_global: + raise ValueError(f"{key} must not point to a private IP address") + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("component", choices=("client", "server")) + parser.add_argument("--base") + parser.add_argument("--manual", action="store_true") + parser.add_argument("--endpoints", action="store_true") + args = parser.parse_args() + if args.endpoints: + validate_endpoints(os.environ) + return + path = "app/pubspec.yaml" if args.component == "client" else "pyproject.toml" + current = parse(git("show", f"HEAD:{path}"), args.component) + if args.manual: + release = True + elif args.base and set(args.base) != {"0"}: + release = decide(current, parse(git("show", f"{args.base}:{path}"), args.component), args.component) + else: + raise ValueError("A previous commit is required; use a manual build to bootstrap") + version, number = current + tag = f"v{version}+{number}" if args.component == "client" else f"v{version}" + existing = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], capture_output=True, text=True + ) + if release and existing.returncode == 0 and existing.stdout.strip() != git("rev-parse", "HEAD"): + raise ValueError(f"{tag} already belongs to another commit; bump the version") + output = f"release={str(release).lower()}\nversion={version}\nbuild_number={number}\ntag={tag}\n" + if os.environ.get("GITHUB_OUTPUT"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as file: + file.write(output) + print(output, end="") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_android_bundle.py b/tools/tests/test_android_bundle.py new file mode 100644 index 00000000..69131d23 --- /dev/null +++ b/tools/tests/test_android_bundle.py @@ -0,0 +1,27 @@ +"""Exercise the native-library check without requiring an Android build.""" + +import importlib.util +import struct +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("android_bundle", Path(__file__).resolve().parents[1] / "check_android_bundle.py") +assert spec is not None and spec.loader is not None +bundle = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bundle) + + +class AlignmentTest(unittest.TestCase): + def elf(self, alignment: int, offset: int = 0) -> bytes: + identity = b"\x7fELF\x02\x01" + bytes(10) + header = struct.pack("<16sHHIQQQIHHHHHH", identity, 3, 183, 1, 0, 64, 0, 0, 64, 56, 1, 0, 0, 0) + return header + struct.pack(" None: + for alignment in (16384, 65536): + bundle.check_elf(self.elf(alignment), "lib.so") + + def test_rejects_4kb_and_incongruent_offsets(self) -> None: + for alignment, offset in ((4096, 0), (16384, 4096)): + with self.assertRaises(ValueError): + bundle.check_elf(self.elf(alignment, offset), "lib.so") diff --git a/tools/tests/test_release.py b/tools/tests/test_release.py new file mode 100644 index 00000000..ee9757e6 --- /dev/null +++ b/tools/tests/test_release.py @@ -0,0 +1,131 @@ +"""Fast, database-free regression checks for release decisions.""" + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +GATE_PATH = Path(__file__).resolve().parents[1] / "release_gate.py" +spec = importlib.util.spec_from_file_location("release_gate", GATE_PATH) +assert spec is not None and spec.loader is not None +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class ReleaseGateTest(unittest.TestCase): + def test_dependency_edit_does_not_release(self) -> None: + first = gate.parse("version: 1.0.0+1\ndependencies: old\n", "client") + second = gate.parse("version: 1.0.0+1\ndependencies: new\n", "client") + self.assertFalse(gate.decide(second, first, "client")) + + def test_each_android_upload_requires_a_new_number(self) -> None: + self.assertTrue(gate.decide(("1.0.0", 2), ("1.0.0", 1), "client")) + self.assertTrue(gate.decide(("1.1.0", 3), ("1.0.0", 2), "client")) + for current in (("1.1.0", 1), ("1.1.0", 2), ("0.9.0", 3)): + with self.assertRaises(ValueError): + gate.decide(current, ("1.0.0", 2), "client") + + def test_server_dependency_edit_does_not_release(self) -> None: + first = gate.parse('[project]\nversion = "1.0.0"\ndependencies = []', "server") + second = gate.parse('[project]\nversion = "1.0.0"\ndependencies = ["fastapi"]', "server") + self.assertFalse(gate.decide(second, first, "server")) + self.assertTrue(gate.decide(("1.0.1", 0), first, "server")) + with self.assertRaises(ValueError): + gate.decide(("0.9.0", 0), first, "server") + + def test_invalid_manifest_versions_are_rejected(self) -> None: + for version in ("1.0.0", "1.0.0+0", "1.0.0+2100000001", "latest"): + with self.assertRaises(ValueError): + gate.parse(f"version: {version}", "client") + + def test_release_endpoints_require_public_https_origins(self) -> None: + good = { + "PAPYRUS_API_BASE_URL": "https://api.papyrusreader.org", + "POWERSYNC_SERVICE_URL": "https://sync.papyrusreader.org", + } + gate.validate_endpoints(good) + for value in ( + "", + "http://api.papyrusreader.org", + "https://localhost", + "https://127.0.0.1", + "https://192.168.1.2", + "https://api.example.invalid", + "https://api.example.com", + "https://api.papyrusreader.org/v1", + "https://user:secret@api.papyrusreader.org", + "https://api.papyrusreader.org?token=x", + ): + with self.subTest(value=value), self.assertRaises(ValueError): + gate.validate_endpoints({**good, "PAPYRUS_API_BASE_URL": value}) + + +class CommittedGateTest(unittest.TestCase): + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.command("init", "-q") + (self.root / "app").mkdir() + self.manifest = self.root / "app/pubspec.yaml" + self.manifest.write_text("version: 1.0.0+1\n") + self.commit() + self.base = self.command("rev-parse", "HEAD") + + def command(self, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(self.root), *args], text=True, stderr=subprocess.DEVNULL + ).strip() + + def commit(self) -> None: + self.command("add", ".") + self.command( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ) + + def run_gate(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(GATE_PATH), "client", *args], + cwd=self.root, + capture_output=True, + text=True, + ) + + def test_compares_committed_values_and_skips_dependency_edits(self) -> None: + self.manifest.write_text("version: 1.0.0+1\ndependencies: changed\n") + self.commit() + result = self.run_gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release=false", result.stdout) + self.manifest.write_text("version: 1.0.0+2\n") + self.commit() + result = self.run_gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("tag=v1.0.0+2", result.stdout) + self.assertIn("release=true", result.stdout) + + def test_released_tag_cannot_be_reused_for_another_commit(self) -> None: + self.command("tag", "v1.0.0+1") + self.manifest.write_text("version: 1.0.0+1\ndependencies: changed\n") + self.commit() + result = self.run_gate("--manual") + self.assertNotEqual(result.returncode, 0) + self.assertIn("already belongs to another commit", result.stderr) + + def test_bootstrap_requires_explicit_manual_build(self) -> None: + self.assertNotEqual(self.run_gate("--base", "0" * 40).returncode, 0) + self.assertEqual(self.run_gate("--manual").returncode, 0) + + +if __name__ == "__main__": + unittest.main()