chore: Sync directories and files from upstream

This commit is contained in:
therajanmaurya 2026-01-05 01:27:29 +00:00 committed by github-actions[bot]
parent ee27d4aa95
commit 80c7bb0270
221 changed files with 7676 additions and 1344 deletions

View File

@ -0,0 +1,65 @@
# This workflow is designed to automate the process of building and deploying a Kotlin/JS web application to GitHub Pages.
# It ensures that whenever changes are merged into the dev branch or when manually triggered, the web application is built,
# packaged, and deployed to the GitHub Pages environment, making it accessible online.
# Key Features:
# - Automated web application build using Kotlin/JS
# - Deployment to GitHub Pages
# - Supports configurable web project module name
# - Manages deployment concurrency and environment settings
# - Provides secure deployment with proper permissions
# Prerequisites:
# - Kotlin Multiplatform/JS project configured with Gradle
# - Web module set up for browser distribution
# - Java 17 or compatible version
# - GitHub Pages enabled in repository settings
# Workflow Configuration:
# - Requires input of `web_package_name` to specify the web project module
# - Uses Windows runner for build process
# - Leverages GitHub Actions for build, pages configuration, and deployment
# Workflow Triggers:
# - Can be manually called from other workflows
# - Supports workflow_call for reusability across projects
# Deployment Process:
# 1. Checkout repository code
# 2. Set up Java development environment
# 3. Build Kotlin/JS web application
# 4. Configure GitHub Pages
# 5. Upload built artifacts
# 6. Deploy to GitHub Pages
# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/build-and-deploy-site.yaml
# ##############################################################################
# DON'T EDIT THIS FILE UNLESS NECESSARY #
# ##############################################################################
name: Build And Deploy Web App
# Trigger conditions for the workflow
on:
workflow_dispatch:
# Concurrency settings to manage multiple workflow runs
# This ensures orderly deployment to production environment
concurrency:
group: "web-pages"
cancel-in-progress: false
permissions:
contents: read # Read repository contents
pages: write # Write to GitHub Pages
id-token: write # Write authentication tokens
pull-requests: write # Write to pull requests
jobs:
build_and_deploy_web:
name: Build And Deploy Web App
uses: openMF/mifos-x-actionhub/.github/workflows/build-and-deploy-site.yaml@v1.0.2
secrets: inherit
with:
web_package_name: 'cmp-web' # <-- Change with your web package name

15
.github/workflows/cache-cleanup.yaml vendored Normal file
View File

@ -0,0 +1,15 @@
name: Cleanup Cache
on:
pull_request:
types: [ closed ]
workflow_dispatch:
jobs:
cleanup:
uses: openMF/mifos-x-actionhub/.github/workflows/cache-cleanup.yaml@v1.0.2
with:
cleanup_pr: ${{ github.event_name == 'pull_request' && github.event.repository.private == true }}
cleanup_all: ${{ github.event_name == 'workflow_dispatch' }}
secrets:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,66 @@
# Automated Monthly Release Versioning Workflow
# ============================================
# Purpose:
# - Automatically create consistent monthly version tags
# - Implement a calendar-based versioning strategy
# - Facilitate easy tracking of monthly releases
# Versioning Strategy:
# - Tag format: YYYY.MM.0 (e.g., 2024.01.0 for January 2024)
# - First digit: Full year
# - Second digit: Month (01-12)
# - Third digit: Patch version (starts at 0, allows for potential updates)
# Key Features:
# - Runs automatically on the first day of each month at 3:30 AM UTC
# - Can be manually triggered via workflow_dispatch
# - Uses GitHub Actions to generate tags programmatically
# - Provides a predictable and systematic versioning approach
# Prerequisites:
# - Repository configured with GitHub Actions
# - Permissions to create tags
# - Access to actions/checkout and tag creation actions
# Workflow Triggers:
# - Scheduled monthly run
# - Manual workflow dispatch
# - Callable from other workflows
# Actions Used:
# 1. actions/checkout@v4 - Checks out repository code
# 2. josStorer/get-current-time - Retrieves current timestamp
# 3. rickstaa/action-create-tag - Creates Git tags
# Example Generated Tags:
# - 2024.01.0 (January 2024 initial release)
# - 2024.02.0 (February 2024 initial release)
# - 2024.02.1 (Potential patch for February 2024)
# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/monthly-version-tag.yaml
# ##############################################################################
# DON'T EDIT THIS FILE UNLESS NECESSARY #
# ##############################################################################
name: Tag Monthly Release
on:
# Allow manual triggering of the workflow
workflow_dispatch:
# Schedule the workflow to run monthly
schedule:
# Runs at 03:30 UTC on the first day of every month
# Cron syntax: minute hour day-of-month month day-of-week
- cron: '30 3 1 * *'
concurrency:
group: "monthly-release"
cancel-in-progress: false
jobs:
monthly_release:
name: Tag Monthly Release
uses: openMF/mifos-x-actionhub/.github/workflows/monthly-version-tag.yaml@v1.0.2
secrets: inherit

View File

@ -71,7 +71,7 @@ on:
target_branch:
type: string
default: 'development'
default: 'dev'
description: 'Target branch for release'
distribute_ios_firebase:
@ -89,16 +89,6 @@ on:
default: false
description: Distribute iOS App to Appstore
distribute_macos_testflight:
type: boolean
default: false
description: Distribute macOS App via TestFlight (App Store Connect)
distribute_macos_appstore:
type: boolean
default: false
description: Distribute macOS App to Appstore
permissions:
contents: write
id-token: write
@ -111,32 +101,27 @@ concurrency:
jobs:
multi_platform_build_and_publish:
name: Multi-Platform Build and Publish
uses: openMF/mifos-x-actionhub/.github/workflows/multi-platform-build-and-publish.yaml@v1.0.7
uses: openMF/mifos-x-actionhub/.github/workflows/multi-platform-build-and-publish.yaml@v1.0.3
with:
java-version: 21
release_type: ${{ inputs.release_type }}
target_branch: ${{ inputs.target_branch }}
android_package_name: 'cmp-android'
ios_package_name: 'cmp-ios'
desktop_package_name: 'cmp-desktop'
web_package_name: 'cmp-web'
tester_groups: 'mifos-mobile-apps'
app_identifier: 'org.mifos.mobile'
android_package_name: 'cmp-android' # <-- Change this to your android package name
ios_package_name: 'cmp-ios' # <-- Change this to your ios package name
desktop_package_name: 'cmp-desktop' # <-- Change this to your desktop package name
web_package_name: 'cmp-web' # <-- Change this to your web package name
tester_groups: 'mifos-mobile-apps' # <-- Change this to your Firebase tester group
app_identifier: 'org.mifos.kmp.template'
git_url: 'git@github.com:openMF/ios-provisioning-profile.git'
git_branch: 'mifos-mobile'
git_branch: 'master'
match_type: 'adhoc'
provisioning_profile_name: 'match AdHoc org.mifos.mobile'
firebase_app_id: '1:728434912738:ios:ee2e0815a6915b351a1dbb'
metadata_path: './fastlane/metadata/ios'
provisioning_profile_name: 'match AdHoc org.mifos.kmp.template'
firebase_app_id: '1:728434912738:ios:1d81f8e53ca7a6f31a1dbb'
metadata_path: './fastlane/metadata'
use_cocoapods: true # <-- Set to true if using CocoaPods integration for KMP
shared_module: ':cmp-shared' # <-- Gradle path to your shared KMP module (e.g., :shared)
cmp_desktop_dir: 'cmp-desktop'
keychain_name: signing.keychain-db # optional
distribute_ios_firebase: ${{ inputs.distribute_ios_firebase }}
distribute_ios_testflight: ${{ inputs.distribute_ios_testflight }}
distribute_ios_appstore: ${{ inputs.distribute_ios_appstore }}
distribute_macos_testflight: ${{ inputs.distribute_macos_testflight }}
distribute_macos_appstore: ${{ inputs.distribute_macos_appstore }}
secrets:
original_keystore_file: ${{ secrets.ORIGINAL_KEYSTORE_FILE }}
original_keystore_file_password: ${{ secrets.ORIGINAL_KEYSTORE_FILE_PASSWORD }}
@ -151,12 +136,6 @@ jobs:
notarization_apple_id: ${{ secrets.NOTARIZATION_APPLE_ID }}
notarization_password: ${{ secrets.NOTARIZATION_PASSWORD }}
notarization_team_id: ${{ secrets.NOTARIZATION_TEAM_ID }}
keychain_password: ${{ secrets.KEYCHAIN_PASSWORD }}
certificates_password: ${{ secrets.CERTIFICATES_PASSWORD }}
mac_app_distribution_certificate_b64: ${{ secrets.MAC_APP_DISTRIBUTION_CERTIFICATE_B64 }}
mac_installer_distribution_certificate_b64: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_B64 }}
mac_embedded_provision_b64: ${{ secrets.MAC_EMBEDDED_PROVISION_B64 }}
mac_runtime_provision_b64: ${{ secrets.MAC_RUNTIME_PROVISION_B64 }}
appstore_key_id: ${{ secrets.APPSTORE_KEY_ID }}
appstore_issuer_id: ${{ secrets.APPSTORE_ISSUER_ID }}
appstore_auth_key: ${{ secrets.APPSTORE_AUTH_KEY }}

View File

@ -13,7 +13,7 @@
### Workflow Jobs
# 1. **Setup**: Prepares the build environment
# - Checks out repository code
# - Sets up Java (configurable; defaults to 17)
# - Sets up Java 17
# - Configures Gradle
# - Manages dependency caching
#
@ -36,7 +36,7 @@
# - Generates platform-specific executables and packages
#
### Prerequisites
# - Java (configurable; default 17)
# - Java 17
# - Gradle
# - Configured build scripts for:
# - Android module
@ -49,16 +49,10 @@
### Configuration Parameters
# The workflow requires two input parameters:
#
# | Parameter | Description | Type | Required |
# |------------------------|------------------------------------|--------|-----------|
# | `android_package_name` | Name of the Android project module | String | Yes |
# | `desktop_package_name` | Name of the Desktop project module | String | Yes |
# |`web_package_name` | Name of the Web (Kotlin/JS) project/module | String | No|
# |`ios_package_name` | Name of the iOS project/module | String | No |
# |`build_ios` | Build iOS targets as part of PR checks | Boolean | No |
# |`use_cocoapods` | Use CocoaPods for iOS integration | Boolean | No |
# |`shared_module | Path of the shared KMP module | String | (required when build_ios=true) |
# |`java-version | Java version to use (configurable; defaults to 17)| No |
# | Parameter | Description | Type | Required |
# |------------------------|------------------------------------|--------|----------|
# | `android_package_name` | Name of the Android project module | String | Yes |
# | `desktop_package_name` | Name of the Desktop project module | String | Yes |
#
# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/pr-check.yaml
@ -68,18 +62,18 @@
# ##############################################################################
name: PR Checks for KMP
name: PR Checks
# Trigger conditions for the workflow
on:
push:
branches: [ development ] # Runs on pushes to dev branch
branches: [ dev ] # Runs on pushes to dev branch
pull_request:
branches: [ development ] # Runs on pushes to dev branch
branches: [ dev ] # Runs on pushes to dev branch
# Concurrency settings to prevent multiple simultaneous workflow runs
concurrency:
group: pr-kmp-${{ github.ref }}
group: pr-${{ github.ref }}
cancel-in-progress: true # Cancels previous runs if a new one is triggered
permissions:
@ -87,9 +81,8 @@ permissions:
jobs:
pr_checks:
name: PR Checks KMP
uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.7
secrets: inherit
name: PR Checks
uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.3
with:
android_package_name: 'cmp-android' # <-- Change Your Android Package Name
desktop_package_name: 'cmp-desktop' # <-- Change Your Desktop Package Name
@ -98,4 +91,3 @@ jobs:
build_ios: true # <-- Change to 'false' if you don't want to build iOS
use_cocoapods: true
shared_module: ':cmp-shared'
java-version: '21'

View File

@ -43,7 +43,7 @@
# end
# ```
# https://github.com/openMF/mifos-mobile-github-actions/blob/main/.github/workflows/promote-to-production.yaml
# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/promote-to-production.yaml
# ##############################################################################
# DON'T EDIT THIS FILE UNLESS NECESSARY #
@ -70,6 +70,6 @@ jobs:
# Job to promote app from beta to production in Play Store
play_promote_production:
name: Promote Beta to Production Play Store
uses: openMF/mifos-x-actionhub/.github/workflows/promote-to-production.yaml@v1.0.7
uses: openMF/mifos-x-actionhub/.github/workflows/promote-to-production.yaml@v1.0.2
secrets:
playstore_creds: ${{ secrets.PLAYSTORECREDS }}

View File

@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: development
ref: dev
- name: Setup Git config
run: |
@ -32,12 +32,14 @@ jobs:
- name: Add upstream remote and fetch
run: |
set -euo pipefail
UPSTREAM="${{ inputs.upstream || 'https://github.com/openMF/kmp-project-template.git' }}"
git remote add upstream "$UPSTREAM" || true
git fetch upstream || exit 1
- name: Check upstream/dev exists
run: |
set -euo pipefail
if ! git rev-parse --verify upstream/dev >/dev/null 2>&1; then
echo "Error: upstream/dev branch does not exist"
exit 1
@ -45,12 +47,16 @@ jobs:
- name: Create and checkout temporary branch
run: |
set -euo pipefail
TEMP_BRANCH="temp-sync-branch-${{ github.run_number }}"
git checkout -b "$TEMP_BRANCH" upstream/dev || exit 1
echo "TEMP_BRANCH=$TEMP_BRANCH" >> $GITHUB_ENV
- name: Sync directories and files
shell: bash
run: |
set -euo pipefail
# Declare directories and files to sync
DIRS=(
"cmp-android"
@ -66,14 +72,14 @@ jobs:
".github"
".run"
)
FILES=(
"Gemfile"
"Gemfile.lock"
"ci-prepush.bat"
"ci-prepush.sh"
)
# Define exclusions
declare -A EXCLUSIONS=(
["cmp-android"]="src/main/res dependencies src/main/ic_launcher-playstore.png google-services.json"
@ -82,25 +88,27 @@ jobs:
["cmp-ios"]="iosApp/Assets.xcassets"
["root"]="secrets.env"
)
# Function to check if path should be excluded
should_exclude() {
local dir=$1
local path=$2
# Check for root exclusions
if [[ "$dir" == "." && -n "${EXCLUSIONS["root"]}" ]]; then
local root_excluded_paths=(${EXCLUSIONS["root"]})
# Check for root exclusions (when dir is "." or "root")
if [[ "$dir" == "." || "$dir" == "root" ]] && [[ -v "EXCLUSIONS[root]" ]]; then
local root_excluded_paths
IFS=' ' read -ra root_excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${root_excluded_paths[@]}"; do
if [[ "$path" == *"$excluded"* ]]; then
return 0
fi
done
fi
# Check directory-specific exclusions
if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
local excluded_paths=(${EXCLUSIONS[$dir]})
if [[ -v "EXCLUSIONS[$dir]" ]]; then
local excluded_paths
IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
if [[ "$path" == *"$excluded"* ]]; then
return 0
@ -109,29 +117,31 @@ jobs:
fi
return 1
}
# Function to preserve excluded paths
preserve_excluded() {
local dir=$1
if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
local excluded_paths=(${EXCLUSIONS[$dir]})
if [[ -v "EXCLUSIONS[$dir]" ]]; then
local excluded_paths
IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
local full_path="$dir/$excluded"
if [[ -e "$full_path" ]]; then
echo "Preserving excluded path: $full_path"
local temp_path="temp_excluded/$full_path"
mkdir -p "$(dirname "$temp_path")"
cp -r "$full_path" "$(dirname "$temp_path")"
cp -r "$full_path" "$(dirname "$temp_path")/"
fi
done
fi
}
# Function to restore excluded paths
restore_excluded() {
local dir=$1
if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
local excluded_paths=(${EXCLUSIONS[$dir]})
if [[ -v "EXCLUSIONS[$dir]" ]]; then
local excluded_paths
IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
local full_path="$dir/$excluded"
local temp_path="temp_excluded/$full_path"
@ -139,16 +149,17 @@ jobs:
echo "Restoring excluded path: $full_path"
mkdir -p "$(dirname "$full_path")"
rm -rf "$full_path"
cp -r "$temp_path" "$(dirname "$full_path")"
cp -r "$temp_path" "$(dirname "$full_path")/"
fi
done
fi
}
# Function to preserve root-level excluded files
preserve_root_files() {
if [[ -n "${EXCLUSIONS["root"]}" ]]; then
local excluded_paths=(${EXCLUSIONS["root"]})
if [[ -v "EXCLUSIONS[root]" ]]; then
local excluded_paths
IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${excluded_paths[@]}"; do
if [[ -e "$excluded" ]]; then
echo "Preserving root-level excluded file: $excluded"
@ -158,11 +169,12 @@ jobs:
done
fi
}
# Function to restore root-level excluded files
restore_root_files() {
if [[ -n "${EXCLUSIONS["root"]}" ]]; then
local excluded_paths=(${EXCLUSIONS["root"]})
if [[ -v "EXCLUSIONS[root]" ]]; then
local excluded_paths
IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${excluded_paths[@]}"; do
if [[ -e "temp_excluded/root/$excluded" ]]; then
echo "Restoring root-level excluded file: $excluded"
@ -171,63 +183,81 @@ jobs:
done
fi
}
# Create temp directory for exclusions
mkdir -p temp_excluded
# Preserve root-level exclusions before sync
preserve_root_files
# Switch to development branch
git checkout development
# Switch to dev branch
git checkout dev
# Sync directories
for dir in "${DIRS[@]}"; do
if [ ! -d "$dir" ]; then
if [[ ! -d "$dir" ]]; then
echo "Creating $dir..."
mkdir -p "$dir"
fi
# Preserve excluded paths before sync
if [[ -d "$dir" ]]; then
preserve_excluded "$dir"
fi
echo "Syncing $dir..."
git checkout "${{ env.TEMP_BRANCH }}" -- "$dir" || exit 1
if ! git checkout "${{ env.TEMP_BRANCH }}" -- "$dir" 2>/dev/null; then
echo "Warning: Could not sync directory $dir (may not exist in upstream)"
fi
# Restore excluded paths after sync
restore_excluded "$dir"
done
# Sync files
for file in "${FILES[@]}"; do
dir=$(dirname "$file")
if ! should_exclude "$dir" "$file"; then
echo "Syncing $file..."
git checkout "${{ env.TEMP_BRANCH }}" -- "$file" || true
else
file_dir=$(dirname "$file")
file_name=$(basename "$file")
# Check root exclusions for root-level files
if [[ "$file_dir" == "." ]]; then
if should_exclude "root" "$file_name"; then
echo "Skipping excluded file: $file"
continue
fi
elif should_exclude "$file_dir" "$file"; then
echo "Skipping excluded file: $file"
continue
fi
echo "Syncing $file..."
if ! git checkout "${{ env.TEMP_BRANCH }}" -- "$file" 2>/dev/null; then
echo "Warning: Could not sync file $file (may not exist in upstream)"
fi
done
# Restore root-level excluded files
restore_root_files
# Cleanup temp directory
rm -rf temp_excluded
echo "Sync completed successfully!"
- name: Clean up temporary branch
if: always()
run: git branch -D "${{ env.TEMP_BRANCH }}" || true
run: git branch -D "${{ env.TEMP_BRANCH }}" 2>/dev/null || true
- name: Check for changes
id: check_changes
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Changes detected:"
git status --short
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No changes detected"
fi
- name: Create Pull Request
@ -239,35 +269,37 @@ jobs:
title: "chore: Sync directories and files from upstream"
body: |
Automated sync of directories and files from upstream repository.
Changes included in this sync:
Directories:
**Directories:**
- cmp-android (excluding src/main/res, dependencies, ic_launcher-playstore.png, google-services.json)
- cmp-desktop (excluding icons)
- cmp-ios (excluding iosApp/Assets.xcassets)
- cmp-web (excluding src/jsMain/resources, src/wasmJsMain/resources)
- cmp-shared
- core-base
- build-logic
- fastlane
- scripts
- config
- .github
- .run
Files:
**Files:**
- Gemfile
- Gemfile.lock
- ci-prepush.bat
- ci-prepush.sh
Root-level exclusions:
**Root-level exclusions:**
- secrets.env
---
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
branch: sync-dirs-${{ github.run_number }}
delete-branch: true
labels: |
sync
automated pr
base: development
base: dev

View File

@ -1,29 +1,104 @@
# Weekly Release Tagging and Beta Deployment Workflow
# ===================================================
# Purpose:
# - Automate weekly version tagging for consistent software versioning
# - Trigger automated beta releases across multiple platforms
# - Maintain a predictable release cycle
# Workflow Overview:
# - Runs automatically every Sunday at 4:00 AM UTC
# - Supports manual triggering via workflow_dispatch
# - Utilizes Gradle Reckon plugin for intelligent versioning
# - Triggers multi-platform build and publish workflow
# Key Features:
# - Automatic semantic versioning
# - Cross-platform release automation
# - Configurable target branch for releases
# - Full repository history checkout for accurate versioning
# Versioning Strategy:
# - Uses Reckon Gradle plugin for semantic versioning
# - Generates production-ready (final) version tags
# - Provides consistent and predictable version incrementation
# Release Process:
# 1. Checkout repository with full commit history
# 2. Setup Java 17 development environment
# 3. Create and push new version tag
# 4. Trigger multi-platform build and publish workflow
# Prerequisites:
# - Gradle project configured with Reckon plugin
# - Java 17 development environment
# - Configured multi-platform build workflow
# - GitHub Actions permissions for workflow dispatch
# Workflow Inputs:
# - target_branch: Branch to use for releases (default: 'dev')
# Allows flexible release targeting across different branches
# Security Considerations:
# - Uses GitHub's native GITHUB_TOKEN for authentication
# - Controlled workflow dispatch with specific inputs
# - Limited to authorized repository members
# Potential Use Cases:
# - Regular software release cycles
# - Automated beta testing distributions
# - Consistent multi-platform deployment
# Workflow Triggers:
# - Scheduled weekly run (Sunday 4:00 AM UTC)
# - Manual workflow dispatch
# - Callable from other workflows
# ##############################################################################
# DON'T EDIT THIS FILE UNLESS NECESSARY #
# ##############################################################################
name: Tag Weekly Release
on:
# Allow manual triggering of the workflow
workflow_dispatch:
# Schedule the workflow to run weekly
schedule:
# Runs at 04:00 UTC every Sunday
# Cron syntax: minute hour day-of-month month day-of-week
- cron: '0 4 * * 0'
concurrency:
group: "weekly-release"
cancel-in-progress: false
jobs:
tag:
name: Tag Weekly Release
runs-on: ubuntu-latest
steps:
# Checkout the repository with full history for proper versioning
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 21
# Setup Java environment for Gradle operations
- name: Set up JDK 17
uses: actions/setup-java@v4.2.2
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
# Create and push a new version tag using Reckon
# This uses the 'final' stage for production-ready releases
- name: Tag Weekly Release
env:
GITHUB_TOKEN: ${{ secrets.TAG_PUSH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :reckonTagPush -Preckon.stage=final
# Trigger the build and publish workflow for beta release
# This starts the process of building and deploying the app to various platforms
- name: Trigger Workflow
uses: actions/github-script@v7
with:
@ -31,9 +106,10 @@ jobs:
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'android-release.yml',
ref: 'development',
workflow_id: 'multi-platform-build-and-publish.yml',
ref: 'dev',
inputs: {
"release_type": "beta",
},
})
})

74
.run/cmp-android.run.xml Normal file
View File

@ -0,0 +1,74 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="cmp-android" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="kmp-project-template.cmp-android" />
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="ALL_USERS" value="false" />
<option name="ALWAYS_INSTALL_WITH_PM" value="false" />
<option name="ALLOW_ASSUME_VERIFIED" value="false" />
<option name="CLEAR_APP_STORAGE" value="false" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="default_activity" />
<option name="RESTORE_ENABLED" value="false" />
<option name="RESTORE_FILE" value="" />
<option name="RESTORE_FRESH_INSTALL_ONLY" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Hybrid>
<Java>
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Java>
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY" value="" />
<option name="ACTIVITY_CLASS" value="" />
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View File

@ -1,11 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="cmp-desktop" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="DEVELOPER_DIR" value="/Applications/Xcode.app/Contents/Developer" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />

View File

@ -10,7 +10,7 @@
</option>
<option name="taskNames">
<list>
<option value=":cmp-web:jsBrowserRun" />
<option value=":cmp-web:jsBrowserDevelopmentRun" />
</list>
</option>
<option name="vmOptions" />

10
Gemfile
View File

@ -1,6 +1,14 @@
source "https://rubygems.org"
ruby '3.3.6'
# Add compatibility gems for Ruby 3.3+
gem "abbrev"
gem "base64"
gem "mutex_m"
gem "bigdecimal"
gem "fastlane"
gem "cocoapods"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View File

@ -5,101 +5,44 @@ GEM
base64
nkf
rexml
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
abbrev (0.1.2)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1048.0)
aws-sdk-core (3.218.1)
aws-eventstream (1.4.0)
aws-partitions (1.1174.0)
aws-sdk-core (3.233.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.98.0)
aws-sdk-core (~> 3, >= 3.216.0)
logger
aws-sdk-kms (1.114.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.180.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-s3 (1.200.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.3.0)
bigdecimal (3.2.2)
base64 (0.3.0)
bigdecimal (3.3.1)
claide (1.1.0)
cocoapods (1.16.2)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.16.2)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 2.1, < 3.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.6.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 2.3.0, < 3.0)
xcodeproj (>= 1.27.0, < 2.0)
cocoapods-core (1.16.2)
activesupport (>= 5.0, < 8)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (2.1)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
drb (2.2.0)
ruby2_keywords
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
@ -117,10 +60,10 @@ GEM
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@ -130,7 +73,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.226.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@ -170,17 +113,14 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-plugin-firebase_app_distribution (0.10.0)
fastlane-plugin-firebase_app_distribution (0.10.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-plugin-increment_build_number (0.0.4)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.1-arm64-darwin)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
@ -202,12 +142,12 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@ -225,46 +165,40 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.9.1)
jwt (2.10.1)
json (2.15.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.20.0)
molinillo (0.8.0)
multi_json (1.15.0)
multi_json (1.17.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.2)
public_suffix (4.0.7)
rake (13.2.1)
public_suffix (6.0.2)
rake (13.3.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.0)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
securerandom (0.3.1)
security (0.1.5)
signet (0.19.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
@ -278,10 +212,6 @@ GEM
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.4.1)
ethon (>= 0.9.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
@ -292,20 +222,26 @@ GEM
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-24
arm64-darwin-25
ruby
DEPENDENCIES
cocoapods
abbrev
base64
bigdecimal
fastlane
fastlane-plugin-firebase_app_distribution
fastlane-plugin-increment_build_number
mutex_m
RUBY VERSION
ruby 3.3.6p108
BUNDLED WITH
2.5.18
2.7.2

View File

@ -29,10 +29,10 @@ setup.
Current list of convention plugins:
- [`mifos.android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
[`mifos.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
[`mifos.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):
- [`android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
[`android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
[`android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):
Configures common Android and Kotlin options.
- [`mifos.android.application.compose`](convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt),
[`mifos.android.library.compose`](convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt):
- [`android.application.compose`](convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt),
[`android.library.compose`](convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt):
Configures Jetpack Compose options

View File

@ -1,20 +1,21 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
`kotlin-dsl`
}
group = "org.mifos.mobile.buildlogic"
group = "org.convention.buildlogic"
// Configure the build-logic plugins to target JDK 17
// Configure the build-logic plugins to target JDK 19
// This matches the JDK used to build the project, and is not related to what is running on device.
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_21.toString()
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
@ -24,14 +25,27 @@ dependencies {
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
compileOnly(libs.detekt.gradlePlugin)
compileOnly(libs.ktlint.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.spotless.gradle)
implementation(libs.truth)
compileOnly(libs.androidx.room.gradle.plugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin)
// Keystore management dependencies
implementation(libs.github.api)
implementation(libs.okhttp)
implementation(libs.jackson.core)
implementation(libs.jackson.databind)
implementation(libs.jackson.module.kotlin)
implementation(libs.commons.codec)
// Test dependencies for keystore management
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.engine)
testImplementation(libs.junit.jupiter.params)
testRuntimeOnly(libs.platform.junit.platform.launcher)
}
tasks {
@ -39,21 +53,30 @@ tasks {
enableStricterValidation = true
failOnWarning = true
}
// Configure JUnit 5 for testing keystore management functionality
test {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
}
gradlePlugin {
plugins {
register("androidApplication") {
id = "mifos.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
// Android Plugins
register("androidApplicationCompose") {
id = "mifos.android.application.compose"
id = "org.convention.android.application.compose"
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
register("androidApplication") {
id = "org.convention.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidFlavors") {
id = "mifos.android.application.flavors"
id = "org.convention.android.application.flavors"
implementationClass = "AndroidApplicationFlavorsConventionPlugin"
}
@ -67,45 +90,6 @@ gradlePlugin {
implementationClass = "AndroidLintConventionPlugin"
}
// This can removed after migration
register("androidLibrary") {
id = "mifos.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidLibraryCompose") {
id = "mifos.android.library.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidFeature") {
id = "mifos.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
// Room Plugin
register("kmpRoom") {
id = "mifos.kmp.room"
implementationClass = "KMPRoomConventionPlugin"
}
// Utility Plugins
register("detekt") {
id = "mifos.detekt.plugin"
implementationClass = "MifosDetektConventionPlugin"
description = "Configures detekt for the project"
}
register("spotless") {
id = "mifos.spotless.plugin"
implementationClass = "MifosSpotlessConventionPlugin"
description = "Configures spotless for the project"
}
register("gitHooks") {
id = "mifos.git.hooks"
implementationClass = "MifosGitHooksConventionPlugin"
description = "Installs git hooks for the project"
}
// KMP & CMP Plugins
register("cmpFeature") {
id = "org.convention.cmp.feature"
@ -116,10 +100,47 @@ gradlePlugin {
id = "org.convention.kmp.koin"
implementationClass = "KMPKoinConventionPlugin"
}
register("kmpLibrary") {
id = "org.convention.kmp.library"
implementationClass = "KMPLibraryConventionPlugin"
}
// Static Analysis & Formatting Plugins
register("detekt") {
id = "org.convention.detekt.plugin"
implementationClass = "DetektConventionPlugin"
description = "Configures detekt for the project"
}
register("spotless") {
id = "org.convention.spotless.plugin"
implementationClass = "SpotlessConventionPlugin"
description = "Configures spotless for the project"
}
register("ktlint") {
id = "org.convention.ktlint.plugin"
implementationClass = "KtlintConventionPlugin"
description = "Configures kotlinter for the project"
}
register("gitHooks") {
id = "org.convention.git.hooks"
implementationClass = "GitHooksConventionPlugin"
description = "Installs git hooks for the project"
}
// Room Plugin
register("KMPRoom"){
id = "mifos.kmp.room"
implementationClass = "KMPRoomConventionPlugin"
description = "Configures Room for the project"
}
// NEW ===============================
register("keystoreManagement") {
id = "org.convention.keystore.management"
implementationClass = "KeystoreManagementConventionPlugin"
description = "Configures keystore management tasks for the project"
}
}
}

View File

@ -1,10 +1,13 @@
import com.android.build.api.dsl.ApplicationExtension
import org.convention.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.mifos.mobile.configureAndroidCompose
/**
* Plugin that applies the Android application and Compose plugins and configures them.
*/
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {

View File

@ -1,37 +1,34 @@
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import org.convention.configureGradleManagedDevices
import org.convention.configureKotlinAndroid
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.mifos.mobile.configureBadgingTasks
import org.mifos.mobile.configureKotlinAndroid
import org.mifos.mobile.configurePrintApksTask
/**
* Plugin that applies the Android application plugin and configures it.
*/
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
apply("com.dropbox.dependency-guard")
apply("mifos.detekt.plugin")
apply("mifos.spotless.plugin")
apply("mifos.git.hooks")
apply("org.convention.detekt.plugin")
apply("org.convention.spotless.plugin")
apply("org.convention.git.hooks")
apply("org.convention.android.application.lint")
apply("org.convention.android.application.firebase")
}
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
}
extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this)
configureBadgingTasks(extensions.getByType<BaseExtension>(), this)
defaultConfig.targetSdk = 36
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
}
}

View File

@ -1,13 +1,27 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ApplicationExtension
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.convention.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.mifos.mobile.libs
class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
@ -34,4 +48,4 @@ class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
}
}
}
}
}

View File

@ -2,8 +2,11 @@ import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.mifos.mobile.configureFlavors
import org.convention.configureFlavors
/**
* Plugin that applies the Android application flavors plugin and configures it.
*/
class AndroidApplicationFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {

View File

@ -1,3 +1,18 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
@ -31,4 +46,4 @@ private fun Lint.configure() {
sarifReport = true
checkDependencies = true
disable += "GradleDependency"
}
}

View File

@ -1,41 +1,59 @@
import org.convention.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.mifos.mobile.libs
/**
* Plugin that applies the CMP feature plugin and configures it.
* This plugin applies the following plugins:
* - org.mifos.kmp.library - Kotlin Multiplatform Library
* - org.mifos.kmp.koin - Koin for Kotlin Multiplatform
* - org.jetbrains.kotlin.plugin.compose - Kotlin Compose
* - org.jetbrains.compose - Compose Multiplatform
* - org.mifos.detekt.plugin - Detekt Plugin
* - org.mifos.spotless.plugin - Spotless Plugin
*
*/
class CMPFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
pluginManager.apply {
apply("org.convention.kmp.library")
apply("org.convention.kmp.koin")
apply("org.jetbrains.kotlin.plugin.compose")
apply("org.jetbrains.compose")
apply("org.convention.detekt.plugin")
apply("org.convention.spotless.plugin")
}
dependencies {
add("commonMainImplementation", project(":core:ui"))
add("commonMainImplementation", project(":core-base:ui"))
add("commonMainImplementation", project(":core:designsystem"))
// add("commonMainImplementation", project(":core:testing"))
add("commonMainImplementation", project(":core-base:designsystem"))
add("commonMainImplementation", project(":core:data"))
add("commonMainImplementation", project(":core-base:designsystem"))
add("commonMainImplementation", project(":core:analytics"))
add("commonMainImplementation", libs.findLibrary("koin.compose").get())
add("commonMainImplementation", libs.findLibrary("koin.compose.viewmodel").get())
add("commonMainImplementation", libs.findLibrary("jb.composeRuntime").get())
add("commonMainImplementation", libs.findLibrary("jb.lifecycle.compose").get())
add("commonMainImplementation", libs.findLibrary("jb.composeViewmodel").get())
add("commonMainImplementation", libs.findLibrary("jb.lifecycleViewmodel").get())
add("commonMainImplementation", libs.findLibrary("jb.lifecycleViewmodelSavedState").get())
add("commonMainImplementation", libs.findLibrary("jb.lifecycle.compose").get())
add(
"commonMainImplementation",
libs.findLibrary("jb.lifecycleViewmodelSavedState").get(),
)
add("commonMainImplementation", libs.findLibrary("jb.savedstate").get())
add("commonMainImplementation", libs.findLibrary("jb.bundle").get())
add("commonMainImplementation", libs.findLibrary("jb.composeNavigation").get())
add("commonMainImplementation", libs.findLibrary("kotlinx.collections.immutable").get())
add("androidMainImplementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("androidMainImplementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("androidMainImplementation", libs.findLibrary("androidx.tracing.ktx").get())
add(
"commonMainImplementation",
libs.findLibrary("kotlinx.collections.immutable").get(),
)
add("androidMainImplementation", platform(libs.findLibrary("koin-bom").get()))
add("androidMainImplementation", libs.findLibrary("koin-android").get())
@ -46,12 +64,7 @@ class CMPFeatureConventionPlugin : Plugin<Project> {
add("androidMainImplementation", libs.findLibrary("koin.androidx.compose").get())
add("androidMainImplementation", libs.findLibrary("koin.core.viewmodel").get())
add("androidTestImplementation", libs.findLibrary("koin.test.junit4").get())
add("androidInstrumentedTestImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidInstrumentedTestImplementation", libs.findLibrary("androidx.compose.ui.test").get())
add("androidInstrumentedTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
}
}
}
}

View File

@ -0,0 +1,26 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.convention.configureDetekt
import org.convention.detektGradle
/**
* Plugin that applies the Detekt plugin and configures it.
*/
class DetektConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
applyPlugins()
detektGradle {
configureDetekt(this)
}
}
}
private fun Project.applyPlugins() {
pluginManager.apply {
apply("io.gitlab.arturbosch.detekt")
}
}
}

View File

@ -31,8 +31,8 @@ class FieldSkippingClassVisitor(
override fun createClassVisitor(
classContext: ClassContext,
nextClassVisitor: ClassVisitor,
): ClassVisitor {
nextClassVisitor: org.objectweb.asm.ClassVisitor,
): org.objectweb.asm.ClassVisitor {
return FieldSkippingClassVisitor(
apiVersion = instrumentationContext.apiVersion.get(),
nextClassVisitor = nextClassVisitor,

View File

@ -0,0 +1,57 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Exec
import org.gradle.kotlin.dsl.register
import java.util.Locale
/**
* Plugin that installs the pre-commit git hooks from the scripts directory.
*/
class GitHooksConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
// Define a function to check if the OS is Linux or MacOS
fun isLinuxOrMacOs(): Boolean {
val osName = System.getProperty("os.name").lowercase(Locale.getDefault())
return osName.contains("linux") || osName.contains("mac os") || osName.contains("macos")
}
// Define the copyGitHooks task
project.tasks.register<Copy>("copyGitHooks") {
description = "Copies the git hooks from /scripts to the .git/hooks folder."
from("${project.rootDir}/scripts/") {
include("**/*.sh")
rename { it.removeSuffix(".sh") }
}
into("${project.rootDir}/.git/hooks")
}
// Define the installGitHooks task
project.tasks.register<Exec>("installGitHooks") {
description = "Installs the pre-commit git hooks from the scripts directory."
group = "git hooks"
workingDir = project.rootDir
if (isLinuxOrMacOs()) {
commandLine("chmod", "-R", "+x", ".git/hooks/")
}else {
commandLine("cmd", "/c", "attrib", "-R", "+X", ".git/hooks/*.*")
}
dependsOn(project.tasks.named("copyGitHooks"))
doLast {
println("Git hooks installed successfully.")
}
}
// Configure task dependencies after evaluation
project.afterEvaluate {
project.tasks.matching {
it.name in listOf("preBuild", "build", "assembleDebug", "assembleRelease", "installDebug", "installRelease", "clean")
}.configureEach {
dependsOn(project.tasks.named("installGitHooks"))
}
}
}
}

View File

@ -1,15 +1,16 @@
import com.google.devtools.ksp.gradle.KspExtension
import org.convention.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.mifos.mobile.libs
/**
* Plugin that applies the Koin plugin and configures it.
*/
class KMPKoinConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target){
with(pluginManager){
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
}
@ -18,14 +19,9 @@ class KMPKoinConventionPlugin : Plugin<Project> {
add("commonMainImplementation", platform(bom))
add("commonMainImplementation", libs.findLibrary("koin.core").get())
add("commonMainImplementation", libs.findLibrary("koin.annotations").get())
add("kspCommonMainMetadata", libs.findLibrary("koin.ksp.compiler").get())
add("commonTestImplementation", libs.findLibrary("koin.test").get())
}
extensions.configure<KspExtension> {
arg("KOIN_CONFIG_CHECK","true")
add("commonTestImplementation", libs.findLibrary("koin.test").get())
}
}
}
}
}

View File

@ -1,35 +1,38 @@
import com.android.build.gradle.LibraryExtension
import org.convention.configureFlavors
import org.convention.configureKotlinAndroid
import org.convention.configureKotlinMultiplatform
import org.convention.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.mifos.mobile.configureFlavors
import org.mifos.mobile.configureKotlinAndroid
import org.mifos.mobile.configureKotlinMultiplatform
import org.mifos.mobile.libs
class KMPLibraryConventionPlugin : Plugin<Project> {
/**
* Plugin that applies the Android library and Kotlin multiplatform plugins and configures them.
*/
class KMPLibraryConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.multiplatform")
apply("org.convention.kmp.koin")
apply("mifos.detekt.plugin")
apply("mifos.spotless.plugin")
apply("org.convention.detekt.plugin")
apply("org.convention.spotless.plugin")
apply("org.jetbrains.kotlin.plugin.serialization")
apply("org.jetbrains.kotlin.plugin.parcelize")
}
configureKotlinMultiplatform()
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
defaultConfig.targetSdk = 36
configureFlavors(this)
/**
* The resource prefix is derived from the module name,
* so resources inside ":core:module1" must be prefixed with "core_module1_"
*/
// The resource prefix is derived from the module name,
// so resources inside ":core:module1" must be prefixed with "core_module1_"
resourcePrefix = path
.split("""\W""".toRegex())
.drop(1).distinct()
@ -38,6 +41,7 @@ class KMPLibraryConventionPlugin : Plugin<Project> {
}
dependencies {
add("commonMainImplementation", libs.findLibrary("kotlinx.serialization.json").get())
add("commonTestImplementation", libs.findLibrary("kotlin.test").get())
add("commonTestImplementation", libs.findLibrary("kotlinx.coroutines.test").get())
}

View File

@ -1,10 +1,10 @@
import androidx.room.gradle.RoomExtension
import com.google.devtools.ksp.gradle.KspExtension
import org.convention.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.mifos.mobile.libs
class KMPRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
@ -24,9 +24,8 @@ class KMPRoomConventionPlugin : Plugin<Project> {
}
dependencies {
"implementation"(libs.findLibrary("androidx.room.runtime").get())
"implementation"(libs.findLibrary("androidx.room.ktx").get())
// Adding ksp dependencies for multiple platforms
"implementation"(libs.findLibrary("androidx.room.ktx").get())
listOf(
"kspDesktop",
"kspAndroid",
@ -36,8 +35,10 @@ class KMPRoomConventionPlugin : Plugin<Project> {
// Add any other platform you may support
).forEach { platform ->
add(platform, libs.findLibrary("androidx.room.compiler").get())
// Kotlin Extensions and Coroutines support for Room
// add(platform, libs.findLibrary("androidx.room.ktx").get())
}
}
}
}
}
}

View File

@ -0,0 +1,153 @@
import org.convention.keystore.ConfigurationFileUpdatesTask
import org.convention.keystore.KeystoreConfig
import org.convention.keystore.KeystoreGenerationTask
import org.convention.keystore.SecretsConfig
import org.convention.keystore.SecretsEnvUpdateTask
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Convention plugin for keystore management following your existing patterns
*/
class KeystoreManagementConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Create extension for configuration
val keystoreExtension = extensions.create("keystoreManagement", KeystoreManagementExtension::class.java)
// Set default configurations
keystoreExtension.keystoreConfig.convention(KeystoreConfig())
keystoreExtension.secretsConfig.convention(SecretsConfig())
// Register the keystore generation task
val generateKeystoresTask = tasks.register("generateKeystores", KeystoreGenerationTask::class.java) {
// Configure task with extension values
keystoreConfig.set(keystoreExtension.keystoreConfig)
secretsConfig.set(keystoreExtension.secretsConfig)
// Load configuration from secrets.env if it exists
KeystoreGenerationTask.createWithSecretsConfig(this, keystoreExtension.secretsConfig.get())
}
// Register configuration file updates task
val updateConfigFilesTask = tasks.register("updateConfigurationFiles", ConfigurationFileUpdatesTask::class.java) {
// Load configuration from secrets.env if it exists
ConfigurationFileUpdatesTask.createWithSecretsConfig(this, keystoreExtension.secretsConfig.get())
}
// Register secrets.env update task (KMPPT-57)
val updateSecretsEnvTask = tasks.register("updateSecretsEnv", SecretsEnvUpdateTask::class.java) {
// Configure to use keystores from generation task
SecretsEnvUpdateTask.createFromKeystoreGeneration(this, generateKeystoresTask.get(), keystoreExtension.secretsConfig.get())
}
// Register combined task that generates keystores and updates config files
tasks.register("generateKeystoresAndUpdateConfigs", KeystoreGenerationTask::class.java) {
keystoreConfig.set(keystoreExtension.keystoreConfig)
secretsConfig.set(keystoreExtension.secretsConfig)
KeystoreGenerationTask.createWithSecretsConfig(this, keystoreExtension.secretsConfig.get())
// Configure the update tasks to run after this task
finalizedBy(updateConfigFilesTask)
finalizedBy(updateSecretsEnvTask)
}
// Configure the update task to use generated keystores
updateConfigFilesTask.configure {
// Set dependency on keystore generation
dependsOn(generateKeystoresTask)
// Configure to use upload keystore from generation task
ConfigurationFileUpdatesTask.createForUploadKeystore(this, generateKeystoresTask.get())
}
// Register convenience tasks for individual keystore types
tasks.register("generateOriginalKeystore", KeystoreGenerationTask::class.java) {
keystoreConfig.set(keystoreExtension.keystoreConfig)
secretsConfig.set(keystoreExtension.secretsConfig)
generateOriginal.set(true)
generateUpload.set(false)
KeystoreGenerationTask.createWithSecretsConfig(this, keystoreExtension.secretsConfig.get())
}
tasks.register("generateUploadKeystore", KeystoreGenerationTask::class.java) {
keystoreConfig.set(keystoreExtension.keystoreConfig)
secretsConfig.set(keystoreExtension.secretsConfig)
generateOriginal.set(false)
generateUpload.set(true)
KeystoreGenerationTask.createWithSecretsConfig(this, keystoreExtension.secretsConfig.get())
}
// Add task group description
tasks.register("keystoreHelp") {
group = "keystore"
description = "Shows available keystore management commands"
doLast {
logger.lifecycle("""
|Keystore Management Plugin - Available Tasks:
|
|Generation Tasks:
| - generateKeystores: Generate both ORIGINAL and UPLOAD keystores
| - generateOriginalKeystore: Generate only the ORIGINAL (debug) keystore
| - generateUploadKeystore: Generate only the UPLOAD (release) keystore
| - generateKeystoresAndUpdateConfigs: Generate keystores and update config files
|
|Configuration Update Tasks:
| - updateConfigurationFiles: Update fastlane and gradle config files with keystore info
| - updateSecretsEnv: Update secrets.env with base64-encoded keystores (KMPPT-57)
|
|Help Tasks:
| - keystoreHelp: Shows this help message
|
|Configuration:
| The plugin automatically loads configuration from 'secrets.env' if it exists.
| You can also configure manually in build.gradle.kts:
|
| keystoreManagement {
| keystoreConfig {
| companyName = "Your Company Name"
| department = "Your Department"
| organization = "Your Organization"
| city = "Your City"
| state = "Your State"
| country = "US"
| keyAlgorithm = "RSA"
| keySize = 2048
| validity = 25
| overwriteExisting = false
| }
| secretsConfig {
| secretsEnvFile = file("secrets.env")
| preserveComments = true
| createBackup = true
| }
| }
|
|Usage Examples:
| ./gradlew generateKeystores # Generate both keystores
| ./gradlew generateOriginalKeystore # Generate debug keystore only
| ./gradlew generateUploadKeystore # Generate release keystore only
| ./gradlew generateKeystoresAndUpdateConfigs # Generate keystores and update configs
| ./gradlew updateConfigurationFiles # Update config files only
| ./gradlew updateSecretsEnv # Update secrets.env with base64 keystores
|
|Note: This task replicates the functionality of keystore-manager.sh
| with better cross-platform compatibility and Gradle integration.
""".trimMargin())
}
}
}
}
}
/**
* Extension class for keystore management configuration
*/
abstract class KeystoreManagementExtension {
abstract val keystoreConfig: org.gradle.api.provider.Property<KeystoreConfig>
abstract val secretsConfig: org.gradle.api.provider.Property<SecretsConfig>
}

View File

@ -0,0 +1,19 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Plugin that applies the Ktlint plugin and configures it.
*/
class KtlintConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
applyPlugins()
}
}
private fun Project.applyPlugins() {
pluginManager.apply {
apply("org.jlleitschuh.gradle.ktlint")
}
}
}

View File

@ -0,0 +1,25 @@
import org.convention.configureSpotless
import org.convention.spotlessGradle
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Plugin that applies the Spotless plugin and configures it.
*/
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
applyPlugins()
spotlessGradle {
configureSpotless(this)
}
}
}
private fun Project.applyPlugins() {
pluginManager.apply {
apply("com.diffplug.spotless")
}
}
}

View File

@ -0,0 +1,63 @@
package org.convention
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
/**
* Configure Compose-specific options
*/
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
buildFeatures {
compose = true
}
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom))
add("androidTestImplementation", platform(bom))
add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
}
testOptions {
unitTests {
// For Robolectric
isIncludeAndroidResources = true
isReturnDefaultValues = true
all {
it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
}
}
}
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
fun Provider<String>.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } }
fun Provider<*>.relativeToRootProject(dir: String) = flatMap {
rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir))
}.map { it.dir(dir) }
project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue()
.relativeToRootProject("compose-metrics")
.let(metricsDestination::set)
project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue()
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFiles
.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
}
}

View File

@ -0,0 +1,19 @@
package org.convention
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Project
/**
* Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder.
* Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following message:
*
* > Starting 0 tests on AVD
*
* Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors.
*/
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
project: Project,
) = beforeVariants {
it.enableAndroidTest = it.enableAndroidTest
&& project.projectDir.resolve("src/androidTest").exists()
}

View File

@ -0,0 +1,9 @@
package org.convention
/**
* This is shared between :app and :benchmarks module to provide configurations type safety.
*/
enum class AppBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"),
RELEASE,
}

View File

@ -0,0 +1,48 @@
package org.convention
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ProductFlavor
@Suppress("EnumEntryName")
enum class FlavorDimension {
contentType
}
// The content for the app can either come from local static data which is useful for demo
// purposes, or from a production backend server which supplies up-to-date, real content.
// These two product flavors reflect this behaviour.
@Suppress("EnumEntryName")
enum class AppFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType)
}
/**
* Configure product flavors for the app module
* @param commonExtension the common extension for the app module
* @param flavorConfigurationBlock the configuration block for each flavor
* @see AppFlavor
*/
fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: AppFlavor) -> Unit = {},
) {
commonExtension.apply {
flavorDimensions += FlavorDimension.contentType.name
productFlavors {
AppFlavor.values().forEach {
create(it.name) {
dimension = it.dimension.name
flavorConfigurationBlock(this, it)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) {
applicationIdSuffix = it.applicationIdSuffix
}
}
}
}
}
}
}

View File

@ -0,0 +1,156 @@
package org.convention
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
import java.io.File
import java.util.Locale
import javax.inject.Inject
/**
* Generates the badging information of the APK.
* This task is cacheable, meaning that if the inputs and outputs have not changed,
* the task will be considered up-to-date and will not run.
* This task is also incremental, meaning that if the inputs have not changed,
*
*/
@CacheableTask
abstract class GenerateBadgingTask : DefaultTask() {
@get:OutputFile
abstract val badging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val apk: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val aapt2Executable: RegularFileProperty
@get:Inject
abstract val execOperations: ExecOperations
@TaskAction
fun taskAction() {
execOperations.exec {
commandLine(
aapt2Executable.get().asFile.absolutePath,
"dump",
"badging",
apk.get().asFile.absolutePath,
)
standardOutput = badging.asFile.get().outputStream()
}
}
}
@CacheableTask
abstract class CheckBadgingTask : DefaultTask() {
// In order for the task to be up-to-date when the inputs have not changed,
// the task must declare an output, even if it's not used. Tasks with no
// output are always run regardless of whether the inputs changed
@get:OutputDirectory
abstract val output: DirectoryProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val goldenBadging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val generatedBadging: RegularFileProperty
@get:Input
abstract val updateBadgingTaskName: Property<String>
override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP
@TaskAction
fun taskAction() {
assertWithMessage(
"Generated badging is different from golden badging! " +
"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}",
)
.that(generatedBadging.get().asFile.readText())
.isEqualTo(goldenBadging.get().asFile.readText())
}
}
fun Project.configureBadgingTasks(
baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension,
) {
// Registers a callback to be called, when a new variant is configured
componentsExtension.onVariants { variant ->
// Registers a new task to verify the app bundle.
val capitalizedVariantName = variant.name.let {
if (it.isEmpty()) it else it[0].titlecase(
Locale.getDefault(),
) + it.substring(1)
}
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging =
tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
apk.set(
variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE),
)
aapt2Executable.set(
File(
baseExtension.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" +
"${baseExtension.buildToolsVersion}/" +
SdkConstants.FN_AAPT2,
),
)
badging.set(
project.layout.buildDirectory.file(
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
),
)
}
val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
tasks.register<Copy>(updateBadgingTaskName) {
from(generateBadging.get().badging)
into(project.layout.projectDirectory)
}
val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
goldenBadging.set(
project.layout.projectDirectory.file("${variant.name}-badging.txt"),
)
generatedBadging.set(
generateBadging.get().badging,
)
this.updateBadgingTaskName.set(updateBadgingTaskName)
output.set(
project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"),
)
}
}
}

View File

@ -0,0 +1,43 @@
package org.convention
import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.named
/**
* Configures the Detekt plugin with the [extension] configuration.
* This includes setting the JVM target to 17 and enabling all reports.
* Additionally, it adds the `detekt-formatting` and `twitter-detekt-compose` plugins.
* @see DetektExtension
* @see Detekt
*/
internal fun Project.configureDetekt(extension: DetektExtension) = extension.apply {
tasks.named<Detekt>("detekt") {
mustRunAfter(":cmp-android:dependencyGuard")
jvmTarget = "17"
source(files(rootDir))
include("**/*.kt")
exclude("**/*.kts")
exclude("**/resources/**")
exclude("**/build/**")
exclude("**/generated/**")
exclude("**/build-logic/**")
exclude("**/spotless/**")
// TODO:: Remove this exclusion
exclude("core-base/designsystem/**")
exclude("feature/home/**")
reports {
xml.required.set(true)
html.required.set(true)
txt.required.set(true)
sarif.required.set(true)
md.required.set(true)
}
}
dependencies {
"detektPlugins"(libs.findLibrary("detekt-formatting").get())
"detektPlugins"(libs.findLibrary("twitter-detekt-compose").get())
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.convention
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.invoke
/**
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")
val pixelC = DeviceConfig("Pixel C", 30, "aosp-atd")
val localDevices = listOf(pixel4, pixel6, pixelC)
val ciDevices = listOf(pixel4, pixelC)
commonExtension.testOptions {
managedDevices {
allDevices {
localDevices.forEach { deviceConfig ->
maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply {
device = deviceConfig.device
apiLevel = deviceConfig.apiLevel
systemImageSource = deviceConfig.systemImageSource
}
}
}
groups {
maybeCreate("ci").apply {
ciDevices.forEach { deviceConfig ->
targetDevices.add(allDevices[deviceConfig.taskName])
}
}
}
}
}
}
private data class DeviceConfig(
val device: String,
val apiLevel: Int,
val systemImageSource: String,
) {
val taskName = buildString {
append(device.lowercase().replace(" ", ""))
append("api")
append(apiLevel.toString())
append(systemImageSource.replace("-", ""))
}
}

View File

@ -0,0 +1,179 @@
/**
* Kotlin Multiplatform project hierarchy template configuration.
*
* This file defines a structured hierarchy for organizing source sets in Kotlin Multiplatform
* projects. It establishes a logical grouping of platform targets that enables efficient code
* sharing across platforms with similar characteristics.
*
* The hierarchy template creates the following logical groupings:
* - `common`: Base shared code for all platforms
* - `nonAndroid`: Code shared between JVM, JS, and native platforms, excluding Android
* - `jsCommon`: Code shared between JavaScript and WebAssembly JavaScript targets
* - `nonJsCommon`: Code shared between JVM and native platforms, excluding JS platforms
* - `jvmCommon`: Code shared between Android and JVM targets
* - `nonJvmCommon`: Code shared between JS and native platforms, excluding JVM platforms
* - `native`: Code shared across all native platforms
* - `apple`: Code shared across Apple platforms (iOS, macOS)
* - `ios`: iOS-specific code
* - `macos`: macOS-specific code
* - `nonNative`: Code shared between JS and JVM platforms
*
* This template applies to both main and test source sets, establishing a consistent
* structure throughout the project.
*
* Note: This implementation uses experimental Kotlin Gradle plugin APIs and may be subject
* to change in future Kotlin releases.
*/
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
package org.convention
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
/**
* Defines the hierarchical structure for source set organization.
*
* This template establishes the relationships between different platform targets,
* creating logical groupings based on platform similarities to facilitate code sharing.
*/
private val hierarchyTemplate = KotlinHierarchyTemplate {
withSourceSetTree(
KotlinSourceSetTree.main,
KotlinSourceSetTree.test,
)
common {
withCompilations { true }
groupNonAndroid()
groupJsCommon()
groupNonJsCommon()
groupJvmCommon()
groupNonJvmCommon()
groupNative()
groupNonNative()
groupJvmJsCommon()
groupMobile()
}
}
/**
* Creates a group of non-Android platforms (JVM, JS, and native).
*/
private fun KotlinHierarchyBuilder.groupNonAndroid() {
group("nonAndroid") {
withJvm()
groupJsCommon()
groupNative()
}
}
/**
* Creates a group of JavaScript-related platforms (JS and WebAssembly JS).
*/
private fun KotlinHierarchyBuilder.groupJsCommon() {
group("jsCommon") {
withJs()
withWasmJs()
}
}
/**
* Creates a group of non-JavaScript platforms (JVM-based and native).
*/
private fun KotlinHierarchyBuilder.groupNonJsCommon() {
group("nonJsCommon") {
groupJvmCommon()
groupNative()
}
}
/**
* Creates a group of JVM-based platforms (Android and JVM).
*/
private fun KotlinHierarchyBuilder.groupJvmCommon() {
group("jvmCommon") {
withAndroidTarget()
withJvm()
}
}
/**
* Creates a group of non-JVM platforms (JavaScript and native).
*/
private fun KotlinHierarchyBuilder.groupNonJvmCommon() {
group("nonJvmCommon") {
groupJsCommon()
groupNative()
}
}
/**
* Creates a group of JVM, JS platforms (JavaScript and JVM).
*/
private fun KotlinHierarchyBuilder.groupJvmJsCommon() {
group("jvmJsCommon") {
groupJsCommon()
withJvm()
}
}
/**
* Creates a hierarchical group of native platforms with subgroups for Apple platforms.
*/
private fun KotlinHierarchyBuilder.groupNative() {
group("native") {
withNative()
group("apple") {
withApple()
group("ios") {
withIos()
}
group("macos") {
withMacos()
}
}
}
}
/**
* Creates a group of non-native platforms (JavaScript and JVM-based).
*/
private fun KotlinHierarchyBuilder.groupNonNative() {
group("nonNative") {
groupJsCommon()
groupJvmCommon()
}
}
private fun KotlinHierarchyBuilder.groupMobile() {
group("mobile") {
withAndroidTarget()
withApple()
}
}
/**
* Applies the predefined hierarchy template to a Kotlin Multiplatform project.
*
* This extension function should be called within the `kotlin` block of a Multiplatform
* project's build script to establish the source set hierarchy defined in this file.
*
* Example usage:
* ```
* kotlin {
* applyProjectHierarchyTemplate()
* // Configure targets...
* }
* ```
*/
fun KotlinMultiplatformExtension.applyProjectHierarchyTemplate() {
applyHierarchyTemplate(hierarchyTemplate)
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.convention
import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ScopedArtifacts
import com.android.build.api.variant.SourceDirectories
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import org.gradle.testing.jacoco.tasks.JacocoReport
import java.util.Locale
private val coverageExclusions = listOf(
// Android
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*_Hilt*.class",
"**/Hilt_*.class",
)
private fun String.capitalize() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
/**
* Creates a new task that generates a combined coverage report with data from local and
* instrumented tests.
*
* `create{variant}CombinedCoverageReport`
*
* Note that coverage data must exist before running the task. This allows us to run device
* tests on CI using a different Github Action or an external device farm.
*/
internal fun Project.configureJacoco(
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
configure<JacocoPluginExtension> {
toolVersion = libs.findVersion("jacoco").get().toString()
}
androidComponentsExtension.onVariants { variant ->
val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask =
tasks.register(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
classDirectories.setFrom(
allJars,
allDirectories.map { dirs ->
dirs.map { dir ->
myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)
}
},
)
reports {
xml.required = true
html.required = true
}
fun SourceDirectories.Flat?.toFilePaths(): Provider<List<String>> = this
?.all
?.map { directories -> directories.map { it.asFile.path } }
?: provider { emptyList() }
sourceDirectories.setFrom(
files(
variant.sources.java.toFilePaths(),
variant.sources.kotlin.toFilePaths()
),
)
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") },
)
}
variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
.use(reportTask)
.toGet(
ScopedArtifact.CLASSES,
{ _ -> allJars },
{ _ -> allDirectories },
)
}
tasks.withType<Test>().configureEach {
configure<JacocoTaskExtension> {
// Required for JaCoCo + Robolectric
// https://github.com/robolectric/robolectric/issues/2230
isIncludeNoLocationClasses = true
// Required for JDK 11 with the above
// https://github.com/gradle/gradle/issues/5184#issuecomment-391982009
excludes = listOf("jdk.internal.*")
}
}
}

View File

@ -0,0 +1,77 @@
package org.convention
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
* Configure base Kotlin with Android options
*/
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
// Up to Java 11 APIs are available through desugaring
// https://developer.android.com/studio/write/java11-minimal-support-table
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
configureKotlin()
dependencies {
add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get())
}
}
/**
* Configure base Kotlin options for JVM (non-Android)
*/
internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> {
// Up to Java 11 APIs are available through desugaring
// https://developer.android.com/studio/write/java11-minimal-support-table
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
configureKotlin()
}
/**
* Configure base Kotlin options
*/
private fun Project.configureKotlin() {
// Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
// Set JVM target to 17
jvmTarget = JvmTarget.JVM_17
// Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs.add(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
}
}

View File

@ -0,0 +1,40 @@
package org.convention
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
/**
* Configure the Kotlin Multiplatform plugin with the default hierarchy template and additional targets.
* This includes JVM, Android, iOS, JS and WASM targets.
* @see KotlinMultiplatformExtension
* @see configure
*/
@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureKotlinMultiplatform() {
extensions.configure<KotlinMultiplatformExtension> {
applyProjectHierarchyTemplate()
jvm("desktop")
androidTarget()
iosSimulatorArm64()
iosX64()
iosArm64()
js(IR) {
this.nodejs()
binaries.executable()
}
wasmJs() {
browser()
nodejs()
}
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
}

View File

@ -0,0 +1,95 @@
package org.convention
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.BuiltArtifactsLoader
import com.android.build.api.variant.HasAndroidTest
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
/**
* Configures the `printTestApks` task for the [project].
* This task will print the location of the androidTest APKs.
* This is useful when running tests on a device or emulator.
* The task will only be created if there are androidTest sources.
* @see PrintApkLocationTask
* @see HasAndroidTest
*/
internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) {
extension.onVariants { variant ->
if (variant is HasAndroidTest) {
val loader = variant.artifacts.getBuiltArtifactsLoader()
val artifact = variant.androidTest?.artifacts?.get(SingleArtifact.APK)
val javaSources = variant.androidTest?.sources?.java?.all
val kotlinSources = variant.androidTest?.sources?.kotlin?.all
val testSources = if (javaSources != null && kotlinSources != null) {
javaSources.zip(kotlinSources) { javaDirs, kotlinDirs ->
javaDirs + kotlinDirs
}
} else javaSources ?: kotlinSources
if (artifact != null && testSources != null) {
tasks.register(
"${variant.name}PrintTestApk",
PrintApkLocationTask::class.java,
) {
apkFolder.set(artifact)
builtArtifactsLoader.set(loader)
variantName.set(variant.name)
sources.set(testSources)
}
}
}
}
}
@DisableCachingByDefault(because = "Prints output")
internal abstract class PrintApkLocationTask : DefaultTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory
abstract val apkFolder: DirectoryProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFiles
abstract val sources: ListProperty<Directory>
@get:Internal
abstract val builtArtifactsLoader: Property<BuiltArtifactsLoader>
@get:Input
abstract val variantName: Property<String>
@TaskAction
fun taskAction() {
val hasFiles = sources.orNull?.any { directory ->
directory.asFileTree.files.any {
it.isFile && "build${File.separator}generated" !in it.parentFile.path
}
} ?: throw RuntimeException("Cannot check androidTest sources")
// Don't print APK location if there are no androidTest source files
if (!hasFiles) return
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs")
if (builtArtifacts.elements.size != 1)
throw RuntimeException("Expected one APK !")
val apk = File(builtArtifacts.elements.single().outputFile).toPath()
println(apk)
}
}

View File

@ -0,0 +1,39 @@
package org.convention
import com.diffplug.gradle.spotless.SpotlessExtension
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder
/**
* Get the `libs` version catalog.
*/
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
/**
* Get the dynamic version of the project.
*/
val Project.dynamicVersion
get() = project.version.toString().split('+')[0]
/**
* Configures the `detekt` plugin with the [configure] lambda.
*/
inline fun Project.detektGradle(crossinline configure: DetektExtension.() -> Unit) =
extensions.configure<DetektExtension> {
configure()
}
/**
* Configures the `spotless` plugin with the [configure] lambda.
*/
inline fun Project.spotlessGradle(crossinline configure: SpotlessExtension.() -> Unit) =
extensions.configure<SpotlessExtension> {
configure()
}

View File

@ -0,0 +1,36 @@
package org.convention
import com.diffplug.gradle.spotless.SpotlessExtension
import org.gradle.api.Project
const val ktlintVersion = "1.0.1"
/**
* Configures the Spotless plugin with the [extension] configuration.
* This includes setting up the `ktlint` formatter and the license header.
* @see SpotlessExtension
*/
internal fun Project.configureSpotless(extension: SpotlessExtension) = extension.apply {
kotlin {
target("**/*.kt")
targetExclude("**/build/**/*.kt")
ktlint(ktlintVersion).editorConfigOverride(
mapOf("android" to "true"),
)
licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
}
format("kts") {
target("**/*.kts")
targetExclude("**/build/**/*.kts")
// Look for the first line that doesn't have a block comment (assumed to be the license)
licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
}
format("xml") {
target("**/*.xml")
targetExclude("**/build/**/*.xml")
// Look for the first XML tag that isn't a comment (<!--) or the xml declaration (<?xml)
licenseHeaderFile(rootProject.file("spotless/copyright.xml"), "(<[^!?])")
}
}

View File

@ -0,0 +1,97 @@
package org.convention.keystore
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.work.DisableCachingByDefault
import java.io.File
/**
* Base class for keystore management tasks following your existing convention patterns
*/
@DisableCachingByDefault(because = "Keystore generation is not a cacheable task")
abstract class BaseKeystoreTask : DefaultTask() {
@get:Input
@get:Optional
abstract val keystoreConfig: Property<KeystoreConfig>
@get:Input
@get:Optional
abstract val secretsConfig: Property<SecretsConfig>
init {
group = "keystore"
description = "Base task for keystore management operations"
// Set default configurations
keystoreConfig.convention(KeystoreConfig())
secretsConfig.convention(SecretsConfig())
}
/**
* Logs task execution with consistent formatting
*/
protected fun logInfo(message: String) {
logger.lifecycle("[KEYSTORE] $message")
}
protected fun logWarning(message: String) {
logger.warn("[KEYSTORE] $message")
}
protected fun logError(message: String) {
logger.error("[KEYSTORE] $message")
}
/**
* Validates configurations before task execution
*/
protected fun validateConfiguration(): Boolean {
val keystoreErrors = keystoreConfig.get().validate()
if (keystoreErrors.isNotEmpty()) {
logError("Keystore configuration errors:")
keystoreErrors.forEach { logError(" - $it") }
return false
}
return true
}
/**
* Creates directory if it doesn't exist
*/
protected fun ensureDirectoryExists(directory: File): Boolean {
return if (!directory.exists()) {
val created = directory.mkdirs()
if (created) {
logInfo("Created directory: ${directory.absolutePath}")
} else {
logError("Failed to create directory: ${directory.absolutePath}")
}
created
} else {
true
}
}
/**
* Checks if keytool is available (matches script check_keytool function)
*/
protected fun checkKeytoolAvailable(): Boolean {
return try {
val process = ProcessBuilder("keytool", "-help").start()
val exitCode = process.waitFor()
if (exitCode == 0) {
logInfo("keytool is available")
true
} else {
logError("keytool command failed")
false
}
} catch (e: Exception) {
logError("keytool not found. Please ensure JDK is installed and keytool is in PATH")
false
}
}
}

View File

@ -0,0 +1,347 @@
package org.convention.keystore
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
/**
* Gradle task for updating configuration files with keystore information
*
* This task replicates the functionality of the keystore-manager.sh script's
* update_fastlane_config and update_gradle_config functions, providing native
* Gradle DSL support for updating configuration files after keystore generation.
*/
@DisableCachingByDefault(because = "Configuration file updates should always run")
abstract class ConfigurationFileUpdatesTask : BaseKeystoreTask() {
@get:Input
abstract val uploadKeystoreConfig: Property<KeystoreConfig>
@get:Input
@get:Optional
abstract val fastlaneConfigPath: Property<String>
@get:Input
@get:Optional
abstract val gradleBuildPath: Property<String>
@get:Input
@get:Optional
abstract val updateFastlane: Property<Boolean>
@get:Input
@get:Optional
abstract val updateGradle: Property<Boolean>
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
abstract val uploadKeystoreFile: RegularFileProperty
init {
description = "Updates configuration files (fastlane and gradle) with keystore information"
// Set default values
fastlaneConfigPath.convention("fastlane-config/android_config.rb")
gradleBuildPath.convention("cmp-android/build.gradle.kts")
updateFastlane.convention(true)
updateGradle.convention(true)
uploadKeystoreConfig.convention(KeystoreConfig.upload())
}
@TaskAction
fun updateConfigurationFiles() {
logInfo("Starting configuration file updates task")
val config = uploadKeystoreConfig.get()
val keystoreFile = uploadKeystoreFile.orNull?.asFile
// Validate keystore configuration
val validationErrors = config.validate()
if (validationErrors.isNotEmpty()) {
throw IllegalArgumentException("Invalid keystore configuration: ${validationErrors.joinToString(", ")}")
}
var fastlaneUpdated = false
var gradleUpdated = false
// Update fastlane configuration if requested
if (updateFastlane.get()) {
val fastlaneFile = File(project.rootDir, fastlaneConfigPath.get())
try {
updateFastlaneConfig(fastlaneFile, config, keystoreFile)
fastlaneUpdated = true
logInfo("Fastlane configuration updated successfully")
} catch (e: Exception) {
logError("Failed to update fastlane configuration: ${e.message}")
throw e
}
}
// Update gradle build file if requested
if (updateGradle.get()) {
val gradleFile = File(project.rootDir, gradleBuildPath.get())
try {
updateGradleConfig(gradleFile, config, keystoreFile)
gradleUpdated = true
logInfo("Gradle build file updated successfully")
} catch (e: Exception) {
logError("Failed to update gradle configuration: ${e.message}")
throw e
}
}
// Print summary
printSummary(fastlaneUpdated, gradleUpdated)
}
/**
* Updates fastlane-config/android_config.rb with keystore information
* Matches the update_fastlane_config() function from keystore-manager.sh
*/
private fun updateFastlaneConfig(configFile: File, config: KeystoreConfig, keystoreFile: File?) {
logInfo("Updating fastlane configuration with keystore information...")
// Determine keystore file name
val keystoreName = keystoreFile?.name ?: config.uploadKeystoreName
// Create the fastlane-config directory if it doesn't exist
val configDir = configFile.parentFile
if (!configDir.exists()) {
logInfo("Creating '${configDir.name}' directory...")
if (!ensureDirectoryExists(configDir)) {
throw IllegalStateException("Failed to create fastlane-config directory")
}
}
// Check if the config file exists
if (configFile.exists()) {
logInfo("Updating existing ${configFile.name}")
updateExistingFastlaneConfig(configFile, config, keystoreName)
} else {
logInfo("Creating new ${configFile.name}")
createNewFastlaneConfig(configFile, config, keystoreName)
}
}
/**
* Updates existing fastlane config file by replacing keystore values
*/
private fun updateExistingFastlaneConfig(configFile: File, config: KeystoreConfig, keystoreName: String) {
val content = configFile.readText()
// Use regex to replace the values while preserving file structure
val updatedContent = content
.replace(Regex("default_store_file:\\s*\"[^\"]*\""), "default_store_file: \"$keystoreName\"")
.replace(
Regex("default_store_password:\\s*\"[^\"]*\""),
"default_store_password: \"${config.keystorePassword}\"",
)
.replace(Regex("default_key_alias:\\s*\"[^\"]*\""), "default_key_alias: \"${config.keyAlias}\"")
.replace(Regex("default_key_password:\\s*\"[^\"]*\""), "default_key_password: \"${config.keyPassword}\"")
configFile.writeText(updatedContent)
}
/**
* Creates new fastlane config file with complete structure
*/
private fun createNewFastlaneConfig(configFile: File, config: KeystoreConfig, keystoreName: String) {
val content = """
module FastlaneConfig
module AndroidConfig
STORE_CONFIG = {
default_store_file: "$keystoreName",
default_store_password: "${config.keystorePassword}",
default_key_alias: "${config.keyAlias}",
default_key_password: "${config.keyPassword}"
}
FIREBASE_CONFIG = {
firebase_prod_app_id: "1:728433984912738:android:3902eb32kjaska3363b0938f1a1dbb",
firebase_demo_app_id: "1:72843493212738:android:8392hjks3298ak9032skja",
firebase_service_creds_file: "secrets/firebaseAppDistributionServiceCredentialsFile.json",
firebase_groups: "mifos-mobile-apps"
}
BUILD_PATHS = {
prod_apk_path: "cmp-android/build/outputs/apk/prod/release/cmp-android-prod-release.apk",
demo_apk_path: "cmp-android/build/outputs/apk/demo/release/cmp-android-demo-release.apk",
prod_aab_path: "cmp-android/build/outputs/bundle/prodRelease/cmp-android-prod-release.aab"
}
end
end
""".trimIndent()
configFile.writeText(content)
}
/**
* Updates cmp-android/build.gradle.kts with keystore information
* Matches the update_gradle_config() function from keystore-manager.sh
*/
private fun updateGradleConfig(gradleFile: File, config: KeystoreConfig, keystoreFile: File?) {
logInfo("Updating Gradle build file with keystore information...")
// Check if the file exists
if (!gradleFile.exists()) {
logWarning("Gradle file not found: ${gradleFile.absolutePath}")
logWarning("Skipping Gradle build file update")
return
}
logInfo("Updating existing ${gradleFile.name}")
// Determine keystore path - use relative path from cmp-android directory
val keystorePath = if (keystoreFile != null) {
val gradleDir = gradleFile.parentFile
val relativePath = gradleDir.toPath().relativize(keystoreFile.toPath()).toString()
relativePath.replace('\\', '/') // Ensure forward slashes for Gradle
} else {
"../keystores/${config.uploadKeystoreName}"
}
// Read the current content
val content = gradleFile.readText()
// Create backup
val backupFile = File(gradleFile.absolutePath + ".bak")
gradleFile.copyTo(backupFile, overwrite = true)
try {
// Use regex to update the signing configuration lines
val updatedContent = content
.replace(
Regex("storeFile = file\\(System\\.getenv\\(\"KEYSTORE_PATH\"\\) \\?\\: \"[^\"]*\"\\)"),
"storeFile = file(System.getenv(\"KEYSTORE_PATH\") ?: \"$keystorePath\")",
)
.replace(
Regex("storePassword = System\\.getenv\\(\"KEYSTORE_PASSWORD\"\\) \\?\\: \"[^\"]*\""),
"storePassword = System.getenv(\"KEYSTORE_PASSWORD\") ?: \"${config.keystorePassword}\"",
)
.replace(
Regex("keyAlias = System\\.getenv\\(\"KEYSTORE_ALIAS\"\\) \\?\\: \"[^\"]*\""),
"keyAlias = System.getenv(\"KEYSTORE_ALIAS\") ?: \"${config.keyAlias}\"",
)
.replace(
Regex("keyPassword = System\\.getenv\\(\"KEYSTORE_ALIAS_PASSWORD\"\\) \\?\\: \"[^\"]*\""),
"keyPassword = System.getenv(\"KEYSTORE_ALIAS_PASSWORD\") ?: \"${config.keyPassword}\"",
)
gradleFile.writeText(updatedContent)
// Remove backup file if update was successful
backupFile.delete()
} catch (e: Exception) {
// Restore from backup if update failed
if (backupFile.exists()) {
backupFile.copyTo(gradleFile, overwrite = true)
backupFile.delete()
}
throw e
}
}
/**
* Prints a summary of the configuration file updates
*/
private fun printSummary(fastlaneUpdated: Boolean, gradleUpdated: Boolean) {
logInfo("")
logInfo("=".repeat(66))
logInfo(" UPDATE SUMMARY")
logInfo("=".repeat(66))
if (updateFastlane.get()) {
val fastlaneFile = File(project.rootDir, fastlaneConfigPath.get())
if (fastlaneUpdated) {
logInfo("Fastlane config: SUCCESS - ${fastlaneFile.absolutePath}")
} else {
logError("Fastlane config: FAILED")
}
}
if (updateGradle.get()) {
val gradleFile = File(project.rootDir, gradleBuildPath.get())
if (gradleUpdated) {
logInfo("Gradle config: SUCCESS - ${gradleFile.absolutePath}")
} else {
logError("Gradle config: FAILED")
}
}
if (fastlaneUpdated || gradleUpdated) {
logInfo("")
logInfo("Configuration files have been updated with keystore information")
}
}
companion object {
/**
* Creates a task configured for the upload keystore from keystore generation task
*/
fun createForUploadKeystore(
task: ConfigurationFileUpdatesTask,
keystoreGenerationTask: KeystoreGenerationTask,
) {
// Use the upload keystore configuration from the generation task
task.uploadKeystoreConfig.set(keystoreGenerationTask.uploadConfig)
// Set the keystore file from the output directory
val keystoreFile = keystoreGenerationTask.outputDirectory.file(
keystoreGenerationTask.uploadConfig.map { it.uploadKeystoreName },
)
task.uploadKeystoreFile.set(keystoreFile)
// Make this task depend on keystore generation
task.dependsOn(keystoreGenerationTask)
}
/**
* Creates a task with configurations loaded from secrets.env file
*/
fun createWithSecretsConfig(
task: ConfigurationFileUpdatesTask,
secretsConfig: SecretsConfig = SecretsConfig(),
) {
// Parse secrets.env file if it exists
val parser = SecretsEnvParser(secretsConfig)
val parseResult = parser.parseFile()
if (parseResult.isValid) {
val secrets = parseResult.allSecrets
// Apply UPLOAD keystore configuration from secrets (used for config file updates)
val uploadKeystoreConfig = KeystoreConfig(
keystorePassword = secrets[secretsConfig.uploadKeystorePasswordKey]
?: KeystoreConfig.upload().keystorePassword,
keyAlias = secrets[secretsConfig.uploadKeystoreAliasKey]
?: KeystoreConfig.upload().keyAlias,
keyPassword = secrets[secretsConfig.uploadKeystoreAliasPasswordKey]
?: KeystoreConfig.upload().keyPassword,
companyName = secrets["COMPANY_NAME"] ?: KeystoreConfig.upload().companyName,
department = secrets["DEPARTMENT"] ?: KeystoreConfig.upload().department,
organization = secrets["ORGANIZATION"] ?: KeystoreConfig.upload().organization,
city = secrets["CITY"] ?: KeystoreConfig.upload().city,
state = secrets["STATE"] ?: KeystoreConfig.upload().state,
country = secrets["COUNTRY"] ?: KeystoreConfig.upload().country,
uploadKeystoreName = secrets["UPLOAD_KEYSTORE_NAME"]
?: KeystoreConfig.upload().uploadKeystoreName,
)
task.uploadKeystoreConfig.set(uploadKeystoreConfig)
task.logger.lifecycle("[KEYSTORE] Loaded configuration from ${secretsConfig.secretsEnvFile.name}")
} else {
task.logger.warn("[KEYSTORE] Could not parse ${secretsConfig.secretsEnvFile.name}: ${parseResult.errors}")
task.logger.lifecycle("[KEYSTORE] Using default configurations")
}
}
}
}

View File

@ -0,0 +1,332 @@
package org.convention.keystore
/**
* Validates configuration parameters for keystore management
* Implements comprehensive validation logic matching keystore-manager.sh script validation
*/
class ConfigurationValidator {
/**
* Validation result containing errors and warnings
*/
data class ValidationResult(
val errors: List<String>,
val warnings: List<String>
) {
val isValid: Boolean get() = errors.isEmpty()
val hasWarnings: Boolean get() = warnings.isNotEmpty()
}
/**
* Validates keystore configuration
*/
fun validateKeystoreConfig(config: KeystoreConfig): ValidationResult {
val errors = mutableListOf<String>()
val warnings = mutableListOf<String>()
// Validate passwords
validatePassword(config.keystorePassword, "keystore password", errors, warnings)
validatePassword(config.keyPassword, "key password", errors, warnings)
// Validate key alias
if (config.keyAlias.isBlank()) {
errors.add("Key alias cannot be blank")
} else if (config.keyAlias.length < 3) {
warnings.add("Key alias is very short (${config.keyAlias.length} characters)")
}
// Validate algorithm and key size
validateKeyAlgorithm(config.keyAlgorithm, config.keySize, errors, warnings)
// Validate validity period
if (config.validity <= 0) {
errors.add("Validity period must be positive")
} else if (config.validity < 1) {
warnings.add("Validity period is very short (${config.validity} years)")
} else if (config.validity > 50) {
warnings.add("Validity period is very long (${config.validity} years)")
}
// Validate distinguished name components
validateDistinguishedName(config, errors, warnings)
// Validate file paths
validateFilePaths(config, errors, warnings)
return ValidationResult(errors, warnings)
}
/**
* Validates secrets configuration
*/
fun validateSecretsConfig(config: SecretsConfig): ValidationResult {
val errors = mutableListOf<String>()
val warnings = mutableListOf<String>()
// Validate file paths
if (config.secretsEnvFile.name.isBlank()) {
errors.add("Secrets file name cannot be blank")
}
if (!config.secretsEnvFile.name.endsWith(".env")) {
warnings.add("Secrets file should have .env extension")
}
// Validate backup settings
if (config.createBackup && config.backupDir.name.isBlank()) {
errors.add("Backup directory name cannot be blank when backup is enabled")
}
// Validate heredoc settings
if (config.useHeredocFormat && config.heredocDelimiter.isBlank()) {
errors.add("Heredoc delimiter cannot be blank when heredoc format is enabled")
}
if (config.base64LineLength <= 0) {
errors.add("Base64 line length must be positive")
}
// Validate secret key names
validateSecretKeyNames(config, errors, warnings)
return ValidationResult(errors, warnings)
}
/**
* Validates environment configuration by checking system properties and environment variables
*/
fun validateEnvironmentConfiguration(): ValidationResult {
val errors = mutableListOf<String>()
val warnings = mutableListOf<String>()
// Check Java version for keytool compatibility
val javaVersion = System.getProperty("java.version")
if (javaVersion != null) {
val majorVersion = extractJavaMajorVersion(javaVersion)
if (majorVersion < 8) {
errors.add("Java 8 or higher is required for keytool. Current version: $javaVersion")
}
} else {
warnings.add("Unable to determine Java version")
}
// Check OS compatibility
val osName = System.getProperty("os.name")?.lowercase()
if (osName?.contains("windows") == true) {
warnings.add("Windows detected. Ensure proper path handling for keystore files")
}
// Check available memory
val maxMemory = Runtime.getRuntime().maxMemory()
val availableMemory = maxMemory / (1024 * 1024) // Convert to MB
if (availableMemory < 256) {
warnings.add("Low available memory ($availableMemory MB). Keystore operations may be slow")
}
return ValidationResult(errors, warnings)
}
/**
* Validates parsed secrets from secrets.env file
*/
fun validateParsedSecrets(parseResult: SecretsEnvParser.ParseResult, config: SecretsConfig): ValidationResult {
val errors = mutableListOf<String>()
val warnings = mutableListOf<String>()
// First check if parsing was successful
if (!parseResult.isValid) {
errors.addAll(parseResult.errors)
return ValidationResult(errors, warnings)
}
val allSecrets = parseResult.allSecrets
// Validate required keystore secrets
validateRequiredKeystoreSecrets(allSecrets, config, errors)
// Validate secret values
validateSecretValues(allSecrets, warnings)
// Check for potential issues
validateSecretPatterns(allSecrets, warnings)
return ValidationResult(errors, warnings)
}
private fun validatePassword(password: String, passwordType: String, errors: MutableList<String>, warnings: MutableList<String>) {
when {
password.isBlank() -> errors.add("$passwordType cannot be blank")
password.length < 6 -> warnings.add("$passwordType is weak (less than 6 characters)")
password == "android" || password == "password" -> warnings.add("$passwordType is using a default/common value")
!password.any { it.isDigit() } -> warnings.add("$passwordType should contain at least one digit")
!password.any { it.isLetter() } -> warnings.add("$passwordType should contain at least one letter")
}
}
private fun validateKeyAlgorithm(algorithm: String, keySize: Int, errors: MutableList<String>, warnings: MutableList<String>) {
when (algorithm.uppercase()) {
"RSA" -> {
when {
keySize < 2048 -> errors.add("RSA key size must be at least 2048 bits")
keySize > 4096 -> warnings.add("RSA key size is very large ($keySize bits), may slow down operations")
}
}
"DSA" -> {
if (keySize < 1024) {
errors.add("DSA key size must be at least 1024 bits")
}
warnings.add("DSA algorithm is less commonly used than RSA")
}
"EC" -> {
if (keySize < 256) {
errors.add("EC key size must be at least 256 bits")
}
}
else -> {
warnings.add("Unknown or uncommon key algorithm: $algorithm")
}
}
}
private fun validateDistinguishedName(config: KeystoreConfig, errors: MutableList<String>, warnings: MutableList<String>) {
// Validate country code
if (config.country.length != 2) {
errors.add("Country code must be exactly 2 characters")
} else if (!config.country.matches(Regex("[A-Z]{2}"))) {
warnings.add("Country code should be uppercase letters")
}
// Check for common DN issues
if (config.companyName.contains("Android Debug") && config.keyAlias != "androiddebugkey") {
warnings.add("Using debug certificate info for non-debug keystore")
}
// Validate required DN components
if (config.companyName.isBlank()) {
warnings.add("Company name (CN) is blank")
}
if (config.organization.isBlank()) {
warnings.add("Organization (O) is blank")
}
}
private fun validateFilePaths(config: KeystoreConfig, errors: MutableList<String>, warnings: MutableList<String>) {
// Check keystore directory
if (!config.keystoreDir.exists() && !config.keystoreDir.mkdirs()) {
errors.add("Cannot create keystore directory: ${config.keystoreDir.absolutePath}")
}
// Check file names
if (!config.originalKeystoreName.endsWith(".keystore") && !config.originalKeystoreName.endsWith(".jks")) {
warnings.add("Original keystore file should have .keystore or .jks extension")
}
if (!config.uploadKeystoreName.endsWith(".keystore") && !config.uploadKeystoreName.endsWith(".jks")) {
warnings.add("Upload keystore file should have .keystore or .jks extension")
}
// Check for file conflicts
if (config.originalKeystorePath.exists() && !config.overwriteExisting) {
warnings.add("Original keystore file already exists and overwrite is disabled")
}
if (config.uploadKeystorePath.exists() && !config.overwriteExisting) {
warnings.add("Upload keystore file already exists and overwrite is disabled")
}
}
private fun validateSecretKeyNames(config: SecretsConfig, errors: MutableList<String>, warnings: MutableList<String>) {
val secretKeys = listOf(
config.originalKeystorePasswordKey,
config.originalKeystoreAliasKey,
config.originalKeystoreAliasPasswordKey,
config.originalKeystoreFileKey,
config.uploadKeystorePasswordKey,
config.uploadKeystoreAliasKey,
config.uploadKeystoreAliasPasswordKey,
config.uploadKeystoreFileKey
)
secretKeys.forEach { key ->
if (key.isBlank()) {
errors.add("Secret key name cannot be blank")
} else if (!key.matches(Regex("[A-Z_][A-Z0-9_]*"))) {
warnings.add("Secret key '$key' should follow UPPER_SNAKE_CASE convention")
}
}
}
private fun validateRequiredKeystoreSecrets(secrets: Map<String, String>, config: SecretsConfig, errors: MutableList<String>) {
val requiredKeys = listOf(
config.originalKeystorePasswordKey,
config.originalKeystoreAliasKey,
config.originalKeystoreAliasPasswordKey,
config.uploadKeystorePasswordKey,
config.uploadKeystoreAliasKey,
config.uploadKeystoreAliasPasswordKey
)
requiredKeys.forEach { key ->
when {
!secrets.containsKey(key) -> errors.add("Missing required secret: $key")
secrets[key].isNullOrBlank() -> errors.add("Empty value for required secret: $key")
}
}
}
private fun validateSecretValues(secrets: Map<String, String>, warnings: MutableList<String>) {
secrets.forEach { (key, value) ->
when {
key.contains("PASSWORD") && value.length < 6 ->
warnings.add("Password secret '$key' is weak (less than 6 characters)")
key.contains("ALIAS") && value.length < 3 ->
warnings.add("Alias secret '$key' is very short")
value.contains("android") && !key.contains("ORIGINAL") ->
warnings.add("Secret '$key' contains 'android' which might be a default value")
}
}
}
private fun validateSecretPatterns(secrets: Map<String, String>, warnings: MutableList<String>) {
// Check for duplicate values
val valueGroups = secrets.values.groupBy { it }
valueGroups.forEach { (value, occurrences) ->
if (occurrences.size > 1 && value.isNotBlank()) {
val keys = secrets.filterValues { it == value }.keys
warnings.add("Duplicate value found in secrets: ${keys.joinToString(", ")}")
}
}
// Check for suspicious patterns
secrets.forEach { (key, value) ->
when {
value.matches(Regex(".*test.*", RegexOption.IGNORE_CASE)) ->
warnings.add("Secret '$key' contains 'test' - ensure this is not a test value")
value.matches(Regex(".*demo.*", RegexOption.IGNORE_CASE)) ->
warnings.add("Secret '$key' contains 'demo' - ensure this is not a demo value")
value.matches(Regex(".*example.*", RegexOption.IGNORE_CASE)) ->
warnings.add("Secret '$key' contains 'example' - ensure this is not an example value")
}
}
}
private fun extractJavaMajorVersion(version: String): Int {
return try {
val parts = version.split(".")
if (parts[0] == "1" && parts.size > 1) {
// Java 1.8 format
parts[1].toInt()
} else {
// Java 9+ format
parts[0].toInt()
}
} catch (e: Exception) {
0 // Unknown version
}
}
}

View File

@ -0,0 +1,306 @@
package org.convention.keystore
import java.io.File
/**
* Handles environment variable overrides for keystore configuration
* Matches the behavior of keystore-manager.sh script's environment variable handling
*/
class EnvironmentOverrideHandler {
/**
* Result of applying environment overrides
*/
data class OverrideResult(
val updatedSecrets: Map<String, String>,
val overriddenKeys: Set<String>,
val warnings: List<String>
)
/**
* Applies environment variable overrides to parsed secrets
* Environment variables take precedence over secrets.env file values
*/
fun applyEnvironmentOverrides(
parsedSecrets: Map<String, String>,
config: SecretsConfig
): OverrideResult {
val updatedSecrets = parsedSecrets.toMutableMap()
val overriddenKeys = mutableSetOf<String>()
val warnings = mutableListOf<String>()
// Get all environment variables
val envVars = System.getenv()
// Apply overrides for all secrets
parsedSecrets.keys.forEach { key ->
val envValue = envVars[key]
if (envValue != null) {
if (envValue != parsedSecrets[key]) {
updatedSecrets[key] = envValue
overriddenKeys.add(key)
// Warn about excluded keys being overridden
if (config.isKeyExcluded(key)) {
warnings.add("Environment variable override for excluded key: $key")
}
}
}
}
// Check for additional environment variables that might be keystore-related
val keystoreRelatedEnvVars = findKeystoreRelatedEnvVars(envVars)
keystoreRelatedEnvVars.forEach { (key, value) ->
if (!updatedSecrets.containsKey(key)) {
updatedSecrets[key] = value
overriddenKeys.add(key)
warnings.add("Added new secret from environment variable: $key")
}
}
return OverrideResult(
updatedSecrets = updatedSecrets,
overriddenKeys = overriddenKeys,
warnings = warnings
)
}
/**
* Creates a KeystoreConfig with environment variable overrides applied
*/
fun createOverriddenKeystoreConfig(baseConfig: KeystoreConfig): KeystoreConfig {
val env = System.getenv()
return baseConfig.copy(
// Basic keystore parameters
keystorePassword = env["KEYSTORE_PASSWORD"] ?: baseConfig.keystorePassword,
keyAlias = env["KEY_ALIAS"] ?: env["KEYSTORE_ALIAS"] ?: baseConfig.keyAlias,
keyPassword = env["KEY_PASSWORD"] ?: env["KEYSTORE_KEY_PASSWORD"] ?: baseConfig.keyPassword,
keyAlgorithm = env["KEYALG"] ?: baseConfig.keyAlgorithm,
keySize = env["KEYSIZE"]?.toIntOrNull() ?: baseConfig.keySize,
validity = env["VALIDITY"]?.toIntOrNull() ?: baseConfig.validity,
// Certificate DN components (using both descriptive and traditional names)
companyName = env["COMPANY_NAME"] ?: env["CN"] ?: baseConfig.companyName,
department = env["DEPARTMENT"] ?: env["OU"] ?: baseConfig.department,
organization = env["ORGANIZATION"] ?: env["O"] ?: baseConfig.organization,
city = env["CITY"] ?: env["L"] ?: baseConfig.city,
state = env["STATE"] ?: env["ST"] ?: baseConfig.state,
country = env["COUNTRY"] ?: env["C"] ?: baseConfig.country,
// File paths
keystoreDir = env["KEYSTORE_DIR"]?.let { File(it) } ?: baseConfig.keystoreDir,
originalKeystoreName = env["ORIGINAL_KEYSTORE_NAME"] ?: baseConfig.originalKeystoreName,
uploadKeystoreName = env["UPLOAD_KEYSTORE_NAME"] ?: baseConfig.uploadKeystoreName,
// Behavior flags
overwriteExisting = env["OVERWRITE"]?.toBoolean() ?: baseConfig.overwriteExisting
)
}
/**
* Creates a SecretsConfig with environment variable overrides applied
*/
fun createOverriddenSecretsConfig(baseConfig: SecretsConfig): SecretsConfig {
val env = System.getenv()
return baseConfig.copy(
// File paths
secretsEnvFile = env["SECRETS_ENV_FILE"]?.let { File(it) } ?: baseConfig.secretsEnvFile,
backupDir = env["SECRETS_BACKUP_DIR"]?.let { File(it) } ?: baseConfig.backupDir,
// Base64 encoding settings
base64LineLength = env["BASE64_LINE_LENGTH"]?.toIntOrNull() ?: baseConfig.base64LineLength,
useHeredocFormat = env["USE_HEREDOC_FORMAT"]?.toBoolean() ?: baseConfig.useHeredocFormat,
heredocDelimiter = env["HEREDOC_DELIMITER"] ?: baseConfig.heredocDelimiter,
// File processing options
createBackup = env["CREATE_BACKUP"]?.toBoolean() ?: baseConfig.createBackup,
preserveComments = env["PRESERVE_COMMENTS"]?.toBoolean() ?: baseConfig.preserveComments
)
}
/**
* Gets environment variables that override keystore file paths
*/
fun getKeystorePathOverrides(): Map<String, String> {
val env = System.getenv()
val pathOverrides = mutableMapOf<String, String>()
// Check for common keystore path environment variables
env["KEYSTORE_PATH"]?.let { pathOverrides["KEYSTORE_PATH"] = it }
env["ORIGINAL_KEYSTORE_PATH"]?.let { pathOverrides["ORIGINAL_KEYSTORE_PATH"] = it }
env["UPLOAD_KEYSTORE_PATH"]?.let { pathOverrides["UPLOAD_KEYSTORE_PATH"] = it }
env["RELEASE_KEYSTORE_PATH"]?.let { pathOverrides["RELEASE_KEYSTORE_PATH"] = it }
env["DEBUG_KEYSTORE_PATH"]?.let { pathOverrides["DEBUG_KEYSTORE_PATH"] = it }
return pathOverrides
}
/**
* Checks if environment contains CI/CD specific variables
*/
fun isRunningInCiCd(): Boolean {
val env = System.getenv()
// Common CI/CD environment indicators
val ciIndicators = listOf(
"CI", "CONTINUOUS_INTEGRATION",
"GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "TEAMCITY_VERSION",
"TRAVIS", "CIRCLECI", "BUILDKITE", "BUILD_NUMBER"
)
return ciIndicators.any { env.containsKey(it) }
}
/**
* Gets CI/CD specific environment information
*/
fun getCiCdEnvironmentInfo(): Map<String, String> {
val env = System.getenv()
val ciInfo = mutableMapOf<String, String>()
// GitHub Actions
env["GITHUB_ACTIONS"]?.let { ciInfo["platform"] = "GitHub Actions" }
env["GITHUB_REPOSITORY"]?.let { ciInfo["repository"] = it }
env["GITHUB_REF"]?.let { ciInfo["ref"] = it }
// GitLab CI
env["GITLAB_CI"]?.let { ciInfo["platform"] = "GitLab CI" }
env["CI_PROJECT_PATH"]?.let { ciInfo["repository"] = it }
env["CI_COMMIT_REF_NAME"]?.let { ciInfo["ref"] = it }
// Jenkins
env["JENKINS_URL"]?.let { ciInfo["platform"] = "Jenkins" }
env["JOB_NAME"]?.let { ciInfo["job"] = it }
env["BUILD_NUMBER"]?.let { ciInfo["build"] = it }
// Generic CI info
env["CI"]?.let { if (ciInfo["platform"] == null) ciInfo["platform"] = "Generic CI" }
return ciInfo
}
/**
* Validates that required environment variables are set for CI/CD
*/
fun validateCiCdEnvironment(requiredSecrets: List<String>): List<String> {
val env = System.getenv()
val errors = mutableListOf<String>()
if (isRunningInCiCd()) {
requiredSecrets.forEach { secret ->
if (!env.containsKey(secret)) {
errors.add("Required environment variable not set in CI/CD: $secret")
} else if (env[secret].isNullOrBlank()) {
errors.add("Required environment variable is empty in CI/CD: $secret")
}
}
// Check for common CI/CD keystore secrets
val commonCiSecrets = listOf(
"KEYSTORE_PASSWORD", "KEY_PASSWORD", "KEYSTORE_ALIAS"
)
commonCiSecrets.forEach { secret ->
if (!env.containsKey(secret)) {
errors.add("Common CI/CD keystore secret not found: $secret")
}
}
}
return errors
}
/**
* Finds environment variables that appear to be keystore-related
*/
private fun findKeystoreRelatedEnvVars(envVars: Map<String, String>): Map<String, String> {
val keystorePatterns = listOf(
".*KEYSTORE.*", ".*KEY_.*", ".*ALIAS.*", ".*STORE_.*",
".*UPLOAD.*", ".*ORIGINAL.*", ".*RELEASE.*", ".*DEBUG.*"
)
return envVars.filterKeys { key ->
keystorePatterns.any { pattern ->
key.matches(Regex(pattern, RegexOption.IGNORE_CASE))
}
}
}
/**
* Sanitizes environment variable values for logging (hides sensitive data)
*/
fun sanitizeEnvVarForLogging(key: String, value: String): String {
val sensitivePatterns = listOf(
".*PASSWORD.*", ".*SECRET.*", ".*KEY.*", ".*TOKEN.*", ".*PRIVATE.*"
)
return if (sensitivePatterns.any { key.matches(Regex(it, RegexOption.IGNORE_CASE)) }) {
if (value.length <= 4) "***" else "${value.take(2)}${"*".repeat(value.length - 4)}${value.takeLast(2)}"
} else {
value
}
}
/**
* Creates a summary of all environment overrides applied
*/
fun createOverrideSummary(overrideResult: OverrideResult): String {
val summary = StringBuilder()
summary.appendLine("Environment Override Summary:")
summary.appendLine("=".repeat(50))
if (overrideResult.overriddenKeys.isEmpty()) {
summary.appendLine("No environment overrides applied")
} else {
summary.appendLine("Overridden keys (${overrideResult.overriddenKeys.size}):")
overrideResult.overriddenKeys.sorted().forEach { key ->
val value = overrideResult.updatedSecrets[key] ?: ""
val sanitizedValue = sanitizeEnvVarForLogging(key, value)
summary.appendLine(" $key = $sanitizedValue")
}
}
if (overrideResult.warnings.isNotEmpty()) {
summary.appendLine("\nWarnings:")
overrideResult.warnings.forEach { warning ->
summary.appendLine("$warning")
}
}
// Add CI/CD information if applicable
if (isRunningInCiCd()) {
summary.appendLine("\nCI/CD Environment Detected:")
getCiCdEnvironmentInfo().forEach { (key, value) ->
summary.appendLine(" $key: $value")
}
}
return summary.toString()
}
companion object {
/**
* Standard environment variable names for keystore configuration
*/
object StandardEnvVars {
const val KEYSTORE_PASSWORD = "KEYSTORE_PASSWORD"
const val KEY_PASSWORD = "KEY_PASSWORD"
const val KEYSTORE_ALIAS = "KEYSTORE_ALIAS"
const val KEYSTORE_PATH = "KEYSTORE_PATH"
const val KEYSTORE_DIR = "KEYSTORE_DIR"
// Original keystore specific
const val ORIGINAL_KEYSTORE_PASSWORD = "ORIGINAL_KEYSTORE_FILE_PASSWORD"
const val ORIGINAL_KEYSTORE_ALIAS = "ORIGINAL_KEYSTORE_ALIAS"
const val ORIGINAL_KEYSTORE_ALIAS_PASSWORD = "ORIGINAL_KEYSTORE_ALIAS_PASSWORD"
// Upload keystore specific
const val UPLOAD_KEYSTORE_PASSWORD = "UPLOAD_KEYSTORE_FILE_PASSWORD"
const val UPLOAD_KEYSTORE_ALIAS = "UPLOAD_KEYSTORE_ALIAS"
const val UPLOAD_KEYSTORE_ALIAS_PASSWORD = "UPLOAD_KEYSTORE_ALIAS_PASSWORD"
}
}
}

View File

@ -0,0 +1,76 @@
package org.convention.keystore
import java.io.File
/**
* Configuration for keystore generation matching keystore-manager.sh script parameters
*/
data class KeystoreConfig(
// Basic keystore parameters
val keystorePassword: String = "android",
val keyAlias: String = "upload",
val keyPassword: String = "android",
val keyAlgorithm: String = "RSA",
val keySize: Int = 2048,
val validity: Int = 25, // years
// Certificate DN components (mapped from script variables)
val companyName: String = "Android Debug", // COMPANY_NAME -> CN
val department: String = "Android", // DEPARTMENT -> OU
val organization: String = "Android", // ORGANIZATION -> O
val city: String = "Unknown", // CITY -> L
val state: String = "Unknown", // STATE -> ST
val country: String = "US", // COUNTRY -> C
// File paths (matching script defaults)
val keystoreDir: File = File("keystores"),
val originalKeystoreName: String = "original.keystore", // ORIGINAL_KEYSTORE_NAME
val uploadKeystoreName: String = "upload.keystore", // UPLOAD_KEYSTORE_NAME
// Behavior flags
val overwriteExisting: Boolean = false // OVERWRITE in script
) {
/**
* Distinguished Name for certificate generation (matches script DN construction)
*/
val distinguishedName: String
get() = "CN=$companyName, OU=$department, O=$organization, L=$city, ST=$state, C=$country"
val originalKeystorePath: File get() = File(keystoreDir, originalKeystoreName)
val uploadKeystorePath: File get() = File(keystoreDir, uploadKeystoreName)
/**
* Validates configuration parameters
*/
fun validate(): List<String> {
val errors = mutableListOf<String>()
if (keystorePassword.isBlank()) errors.add("Keystore password cannot be blank")
if (keyAlias.isBlank()) errors.add("Key alias cannot be blank")
if (keyPassword.isBlank()) errors.add("Key password cannot be blank")
if (keySize < 1024) errors.add("Key size must be at least 1024 bits")
if (validity <= 0) errors.add("Validity period must be positive")
if (country.length != 2) errors.add("Country code must be exactly 2 characters")
return errors
}
companion object {
/**
* Creates default configuration matching script's ORIGINAL keystore
*/
fun original(): KeystoreConfig = KeystoreConfig(
companyName = "Android Debug",
keyAlias = "androiddebugkey",
originalKeystoreName = "original.keystore"
)
/**
* Creates default configuration matching script's UPLOAD keystore
*/
fun upload(): KeystoreConfig = KeystoreConfig(
companyName = "Android Release",
keyAlias = "upload",
uploadKeystoreName = "upload.keystore"
)
}
}

View File

@ -0,0 +1,353 @@
package org.convention.keystore
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Gradle task for generating Android keystores using keytool
*
* This task replicates the functionality of the keystore-manager.sh script's
* generate_keystore and generate_keystores functions, providing native Gradle DSL
* support for cross-platform keystore generation.
*/
@DisableCachingByDefault(because = "Keystore generation is not a cacheable task")
abstract class KeystoreGenerationTask : BaseKeystoreTask() {
@get:Input
@get:Optional
abstract val generateOriginal: Property<Boolean>
@get:Input
@get:Optional
abstract val generateUpload: Property<Boolean>
@get:Input
@get:Optional
abstract val originalConfig: Property<KeystoreConfig>
@get:Input
@get:Optional
abstract val uploadConfig: Property<KeystoreConfig>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
init {
description = "Generates Android keystores (ORIGINAL and UPLOAD) using keytool"
// Set default values
generateOriginal.convention(true)
generateUpload.convention(true)
outputDirectory.convention(project.layout.projectDirectory.dir("keystores"))
// Set default keystore configurations
originalConfig.convention(KeystoreConfig.original())
uploadConfig.convention(KeystoreConfig.upload())
}
@TaskAction
fun generateKeystores() {
logInfo("Starting keystore generation task")
// Validate keytool availability
if (!checkKeytoolAvailable()) {
throw IllegalStateException("keytool is not available. Please ensure JDK is installed and keytool is in PATH.")
}
// Create keystores directory
val keystoreDir = outputDirectory.asFile.get()
if (!ensureDirectoryExists(keystoreDir)) {
throw IllegalStateException("Failed to create keystores directory: ${keystoreDir.absolutePath}")
}
var originalResult = true
var uploadResult = true
// Generate ORIGINAL keystore if requested
if (generateOriginal.get()) {
logInfo("=".repeat(66))
logInfo("Generating ORIGINAL keystore")
logInfo("=".repeat(66))
val originalKeystoreConfig = originalConfig.get()
originalResult = generateKeystore(
environment = "ORIGINAL",
config = originalKeystoreConfig,
keystorePath = File(keystoreDir, originalKeystoreConfig.originalKeystoreName)
)
}
// Generate UPLOAD keystore if requested
if (generateUpload.get()) {
logInfo("=".repeat(66))
logInfo("Generating UPLOAD keystore")
logInfo("=".repeat(66))
val uploadKeystoreConfig = uploadConfig.get()
uploadResult = generateKeystore(
environment = "UPLOAD",
config = uploadKeystoreConfig,
keystorePath = File(keystoreDir, uploadKeystoreConfig.uploadKeystoreName)
)
}
// Print summary
printSummary(originalResult, uploadResult, keystoreDir)
// Fail the task if any keystore generation failed
if (!originalResult || !uploadResult) {
throw IllegalStateException("One or more keystores failed to generate")
}
}
/**
* Generates a single keystore using keytool (matches script's generate_keystore function)
*/
private fun generateKeystore(
environment: String,
config: KeystoreConfig,
keystorePath: File
): Boolean {
try {
// Log keystore parameters
logKeystoreParameters(environment, config, keystorePath)
// Check if keystore already exists and handle overwrite behavior
if (keystorePath.exists()) {
if (config.overwriteExisting) {
logInfo("Overwriting existing keystore file '${keystorePath.name}'")
} else {
logInfo("Keystore file '${keystorePath.name}' already exists and OVERWRITE is not set to 'true'")
logInfo("Using existing keystore")
return true
}
}
// Build keytool command
val command = buildKeytoolCommand(config, keystorePath)
// Execute keytool command
logInfo("Executing keytool command...")
val success = executeKeytoolCommand(command)
if (success && keystorePath.exists()) {
logInfo("")
logInfo("===== $environment Keystore created successfully! =====")
logInfo("Keystore location: ${keystorePath.absolutePath}")
logInfo("Keystore alias: ${config.keyAlias}")
logInfo("")
return true
} else {
logError("")
logError("Error: Failed to create $environment keystore. Please check the error messages above.")
return false
}
} catch (e: Exception) {
logError("Exception during $environment keystore generation: ${e.message}")
e.printStackTrace()
return false
}
}
/**
* Logs keystore generation parameters (matches script output format)
*/
private fun logKeystoreParameters(environment: String, config: KeystoreConfig, keystorePath: File) {
logInfo("Generating keystore with the following parameters:")
logInfo("- Environment: $environment")
logInfo("- Keystore Name: ${keystorePath.absolutePath}")
logInfo("- Key Alias: ${config.keyAlias}")
logInfo("- Validity: ${config.validity} years")
logInfo("- Key Algorithm: ${config.keyAlgorithm}")
logInfo("- Key Size: ${config.keySize}")
logInfo("- Distinguished Name: ${config.distinguishedName}")
}
/**
* Builds the keytool command arguments (matches script's keytool invocation)
*/
private fun buildKeytoolCommand(config: KeystoreConfig, keystorePath: File): List<String> {
return listOf(
"keytool",
"-genkey",
"-v",
"-keystore", keystorePath.absolutePath,
"-alias", config.keyAlias,
"-keyalg", config.keyAlgorithm,
"-keysize", config.keySize.toString(),
"-validity", (config.validity * 365).toString(), // Convert years to days
"-storepass", config.keystorePassword,
"-keypass", config.keyPassword,
"-dname", config.distinguishedName
)
}
/**
* Executes the keytool command with proper error handling
*/
private fun executeKeytoolCommand(command: List<String>): Boolean {
return try {
logInfo("Command: ${command.joinToString(" ") { if (it.contains(" ")) "\"$it\"" else it }}")
val processBuilder = ProcessBuilder(command)
processBuilder.redirectErrorStream(true)
val process = processBuilder.start()
// Capture output
val output = process.inputStream.bufferedReader().readText()
// Wait for process to complete with timeout
val finished = process.waitFor(60, TimeUnit.SECONDS)
if (!finished) {
logError("Keytool command timed out after 60 seconds")
process.destroyForcibly()
return false
}
val exitCode = process.exitValue()
if (exitCode == 0) {
logInfo("Keytool command completed successfully")
if (output.isNotBlank()) {
logInfo("Keytool output:")
output.lines().forEach { line ->
if (line.isNotBlank()) {
logInfo(" $line")
}
}
}
true
} else {
logError("Keytool command failed with exit code: $exitCode")
if (output.isNotBlank()) {
logError("Keytool error output:")
output.lines().forEach { line ->
if (line.isNotBlank()) {
logError(" $line")
}
}
}
false
}
} catch (e: Exception) {
logError("Failed to execute keytool command: ${e.message}")
e.printStackTrace()
false
}
}
/**
* Prints summary of keystore generation results (matches script summary format)
*/
private fun printSummary(originalResult: Boolean, uploadResult: Boolean, keystoreDir: File) {
logInfo("")
logInfo("=".repeat(66))
logInfo(" SUMMARY")
logInfo("=".repeat(66))
if (generateOriginal.get()) {
val originalKeystorePath = File(keystoreDir, originalConfig.get().originalKeystoreName)
if (originalResult) {
logInfo("ORIGINAL keystore: SUCCESS - ${originalKeystorePath.absolutePath}")
} else {
logError("ORIGINAL keystore: FAILED")
}
}
if (generateUpload.get()) {
val uploadKeystorePath = File(keystoreDir, uploadConfig.get().uploadKeystoreName)
if (uploadResult) {
logInfo("UPLOAD keystore: SUCCESS - ${uploadKeystorePath.absolutePath}")
} else {
logError("UPLOAD keystore: FAILED")
}
}
logInfo("")
logInfo("IMPORTANT: Keep these keystore files and their passwords in a safe place.")
logInfo("If you lose them, you will not be able to update your application on the Play Store.")
}
companion object {
/**
* Creates a task with configurations loaded from secrets.env file
*/
fun createWithSecretsConfig(
task: KeystoreGenerationTask,
secretsConfig: SecretsConfig = SecretsConfig()
) {
// Parse secrets.env file if it exists
val parser = SecretsEnvParser(secretsConfig)
val parseResult = parser.parseFile()
if (parseResult.isValid) {
val secrets = parseResult.allSecrets
// Apply ORIGINAL keystore configuration from secrets
val originalKeystoreConfig = KeystoreConfig(
keystorePassword = secrets[secretsConfig.originalKeystorePasswordKey]
?: KeystoreConfig.original().keystorePassword,
keyAlias = secrets[secretsConfig.originalKeystoreAliasKey]
?: KeystoreConfig.original().keyAlias,
keyPassword = secrets[secretsConfig.originalKeystoreAliasPasswordKey]
?: KeystoreConfig.original().keyPassword,
companyName = secrets["COMPANY_NAME"] ?: KeystoreConfig.original().companyName,
department = secrets["DEPARTMENT"] ?: KeystoreConfig.original().department,
organization = secrets["ORGANIZATION"] ?: KeystoreConfig.original().organization,
city = secrets["CITY"] ?: KeystoreConfig.original().city,
state = secrets["STATE"] ?: KeystoreConfig.original().state,
country = secrets["COUNTRY"] ?: KeystoreConfig.original().country,
keyAlgorithm = secrets["KEYALG"] ?: KeystoreConfig.original().keyAlgorithm,
keySize = secrets["KEYSIZE"]?.toIntOrNull() ?: KeystoreConfig.original().keySize,
validity = secrets["VALIDITY"]?.toIntOrNull() ?: KeystoreConfig.original().validity,
originalKeystoreName = secrets["ORIGINAL_KEYSTORE_NAME"]
?: KeystoreConfig.original().originalKeystoreName,
overwriteExisting = secrets["OVERWRITE"]?.toBoolean()
?: KeystoreConfig.original().overwriteExisting
)
// Apply UPLOAD keystore configuration from secrets
val uploadKeystoreConfig = KeystoreConfig(
keystorePassword = secrets[secretsConfig.uploadKeystorePasswordKey]
?: KeystoreConfig.upload().keystorePassword,
keyAlias = secrets[secretsConfig.uploadKeystoreAliasKey]
?: KeystoreConfig.upload().keyAlias,
keyPassword = secrets[secretsConfig.uploadKeystoreAliasPasswordKey]
?: KeystoreConfig.upload().keyPassword,
companyName = secrets["COMPANY_NAME"] ?: KeystoreConfig.upload().companyName,
department = secrets["DEPARTMENT"] ?: KeystoreConfig.upload().department,
organization = secrets["ORGANIZATION"] ?: KeystoreConfig.upload().organization,
city = secrets["CITY"] ?: KeystoreConfig.upload().city,
state = secrets["STATE"] ?: KeystoreConfig.upload().state,
country = secrets["COUNTRY"] ?: KeystoreConfig.upload().country,
keyAlgorithm = secrets["KEYALG"] ?: KeystoreConfig.upload().keyAlgorithm,
keySize = secrets["KEYSIZE"]?.toIntOrNull() ?: KeystoreConfig.upload().keySize,
validity = secrets["VALIDITY"]?.toIntOrNull() ?: KeystoreConfig.upload().validity,
uploadKeystoreName = secrets["UPLOAD_KEYSTORE_NAME"]
?: KeystoreConfig.upload().uploadKeystoreName,
overwriteExisting = secrets["OVERWRITE"]?.toBoolean()
?: KeystoreConfig.upload().overwriteExisting
)
task.originalConfig.set(originalKeystoreConfig)
task.uploadConfig.set(uploadKeystoreConfig)
task.logger.lifecycle("[KEYSTORE] Loaded configuration from ${secretsConfig.secretsEnvFile.name}")
} else {
task.logger.warn("[KEYSTORE] Could not parse ${secretsConfig.secretsEnvFile.name}: ${parseResult.errors}")
task.logger.lifecycle("[KEYSTORE] Using default configurations")
}
}
}
}

View File

@ -0,0 +1,46 @@
package org.convention.keystore
import org.gradle.api.logging.Logger
/**
* Utility class for consistent keystore task logging
*/
class KeystoreLogger(private val logger: Logger) {
companion object {
private const val PREFIX = "[KEYSTORE]"
private const val SEPARATOR = "=============================================================================="
}
fun info(message: String) {
logger.lifecycle("$PREFIX $message")
}
fun warning(message: String) {
logger.warn("$PREFIX $message")
}
fun error(message: String) {
logger.error("$PREFIX $message")
}
fun success(message: String) {
logger.lifecycle("$PREFIX$message")
}
fun section(title: String) {
logger.lifecycle("")
logger.lifecycle("$PREFIX $SEPARATOR")
logger.lifecycle("$PREFIX $title")
logger.lifecycle("$PREFIX $SEPARATOR")
}
fun summary(title: String, items: List<Pair<String, Boolean>>) {
logger.lifecycle("")
section(title)
items.forEach { (item, success) ->
val status = if (success) "✅ SUCCESS" else "❌ FAILED"
logger.lifecycle("$PREFIX $item: $status")
}
}
}

View File

@ -0,0 +1,65 @@
package org.convention.keystore
import java.io.File
/**
* Configuration for secrets.env file management matching keystore-manager.sh functionality
*/
data class SecretsConfig(
// File paths
val secretsEnvFile: File = File("secrets.env"),
val backupDir: File = File("secrets-backup"),
// GitHub secret names from script
val originalKeystorePasswordKey: String = "ORIGINAL_KEYSTORE_FILE_PASSWORD",
val originalKeystoreAliasKey: String = "ORIGINAL_KEYSTORE_ALIAS",
val originalKeystoreAliasPasswordKey: String = "ORIGINAL_KEYSTORE_ALIAS_PASSWORD",
val originalKeystoreFileKey: String = "ORIGINAL_KEYSTORE_FILE",
val uploadKeystorePasswordKey: String = "UPLOAD_KEYSTORE_FILE_PASSWORD",
val uploadKeystoreAliasKey: String = "UPLOAD_KEYSTORE_ALIAS",
val uploadKeystoreAliasPasswordKey: String = "UPLOAD_KEYSTORE_ALIAS_PASSWORD",
val uploadKeystoreFileKey: String = "UPLOAD_KEYSTORE_FILE",
// Excluded keys from GitHub (matching script EXCLUDED_GITHUB_KEYS)
val excludedGitHubKeys: Set<String> = setOf(
"COMPANY_NAME", "DEPARTMENT", "ORGANIZATION", "CITY", "STATE", "COUNTRY",
"VALIDITY", "KEYALG", "KEYSIZE", "OVERWRITE",
"ORIGINAL_KEYSTORE_NAME", "UPLOAD_KEYSTORE_NAME",
"CN", "OU", "O", "L", "ST", "C"
),
// Base64 encoding settings
val base64LineLength: Int = 76,
val useHeredocFormat: Boolean = true,
val heredocDelimiter: String = "EOF",
// File processing options
val createBackup: Boolean = true,
val preserveComments: Boolean = true
) {
/**
* Checks if a key should be excluded from GitHub secrets
*/
fun isKeyExcluded(key: String): Boolean = excludedGitHubKeys.contains(key)
/**
* Formats a multiline value using heredoc syntax (matching script format)
*/
fun formatMultilineValue(key: String, value: String): String {
return if (useHeredocFormat) {
"$key<<$heredocDelimiter\n$value\n$heredocDelimiter"
} else {
val escapedValue = value.replace("\n", "\\n").replace("\"", "\\\"")
"$key=\"$escapedValue\""
}
}
/**
* Gets backup file path with timestamp
*/
fun getBackupFile(timestamp: String = System.currentTimeMillis().toString()): File {
return File(backupDir, "secrets.env.backup.$timestamp")
}
}

View File

@ -0,0 +1,243 @@
package org.convention.keystore
import java.io.File
/**
* Parser for secrets.env file with multiline (heredoc) support
* Matches the functionality of the keystore-manager.sh script's load_env_vars function
*/
class SecretsEnvParser(private val config: SecretsConfig) {
/**
* Parsed result containing all secrets and metadata
*/
data class ParseResult(
val secrets: Map<String, String>,
val multilineSecrets: Map<String, String>,
val comments: List<String>,
val errors: List<String>
) {
val allSecrets: Map<String, String>
get() = secrets + multilineSecrets
val isValid: Boolean
get() = errors.isEmpty()
}
/**
* Parses the secrets.env file with full heredoc support
* Matches the exact behavior of the bash script's multiline parsing
*/
fun parseFile(file: File = config.secretsEnvFile): ParseResult {
if (!file.exists()) {
return ParseResult(
secrets = emptyMap(),
multilineSecrets = emptyMap(),
comments = emptyList(),
errors = listOf("File not found: ${file.absolutePath}")
)
}
val secrets = mutableMapOf<String, String>()
val multilineSecrets = mutableMapOf<String, String>()
val comments = mutableListOf<String>()
val errors = mutableListOf<String>()
var lineNumber = 0
var inMultilineBlock = false
var multilineKey = ""
var multilineEnd = ""
val multilineValue = StringBuilder()
try {
file.forEachLine { line ->
lineNumber++
when {
// Handle multiline block termination
inMultilineBlock && line.trim() == multilineEnd -> {
multilineSecrets[multilineKey] = multilineValue.toString()
inMultilineBlock = false
multilineKey = ""
multilineEnd = ""
multilineValue.clear()
}
// Handle content inside multiline blocks
inMultilineBlock -> {
if (multilineValue.isNotEmpty()) {
multilineValue.appendLine()
}
multilineValue.append(line)
}
// Skip empty lines and comments when not in multiline mode
line.isBlank() || line.trimStart().startsWith("#") -> {
if (config.preserveComments && line.trimStart().startsWith("#")) {
comments.add(line)
}
}
// Check for multiline block start (KEY<<DELIMITER)
line.contains("<<") -> {
val parts = line.split("<<", limit = 2)
if (parts.size == 2) {
multilineKey = parts[0].trim()
multilineEnd = parts[1].trim()
if (multilineKey.isBlank()) {
errors.add("Line $lineNumber: Empty key in multiline declaration")
} else if (multilineEnd.isBlank()) {
errors.add("Line $lineNumber: Empty delimiter in multiline declaration")
} else {
inMultilineBlock = true
multilineValue.clear()
}
} else {
errors.add("Line $lineNumber: Invalid multiline syntax")
}
}
// Handle regular KEY=VALUE pairs
line.contains("=") -> {
val parts = line.split("=", limit = 2)
if (parts.size == 2) {
val key = parts[0].trim()
val rawValue = parts[1]
if (key.isBlank()) {
errors.add("Line $lineNumber: Empty key")
} else {
val cleanValue = stripQuotes(rawValue)
secrets[key] = cleanValue
}
} else {
errors.add("Line $lineNumber: Invalid key=value syntax")
}
}
else -> {
errors.add("Line $lineNumber: Unrecognized line format: $line")
}
}
}
// Check for unterminated multiline blocks
if (inMultilineBlock) {
errors.add("Unterminated multiline block. Missing closing delimiter: $multilineEnd")
}
} catch (e: Exception) {
errors.add("Failed to read file: ${e.message}")
}
return ParseResult(
secrets = secrets,
multilineSecrets = multilineSecrets,
comments = comments,
errors = errors
)
}
/**
* Strips surrounding quotes from values and handles escape sequences (matches script's strip_quotes function)
*/
private fun stripQuotes(value: String): String {
var cleaned = value.trim()
// Remove surrounding double quotes and handle escape sequences
if (cleaned.startsWith("\"") && cleaned.endsWith("\"") && cleaned.length > 1) {
cleaned = cleaned.substring(1, cleaned.length - 1)
// Unescape common escape sequences in double-quoted strings
cleaned = cleaned
.replace("\\\"", "\"") // Unescape double quotes
.replace("\\\\", "\\") // Unescape backslashes
.replace("\\n", "\n") // Unescape newlines
.replace("\\t", "\t") // Unescape tabs
.replace("\\r", "\r") // Unescape carriage returns
}
// Remove surrounding single quotes (no escape sequence processing for single quotes)
if (cleaned.startsWith("'") && cleaned.endsWith("'") && cleaned.length > 1) {
cleaned = cleaned.substring(1, cleaned.length - 1)
}
return cleaned
}
/**
* Creates a formatted table view of secrets (matches script's view_secrets function)
*/
fun formatSecretsTable(parseResult: ParseResult): String {
if (!parseResult.isValid) {
return "Error parsing secrets file:\n${parseResult.errors.joinToString("\n")}"
}
val keyWidth = 30
val valueWidth = 50
val totalWidth = keyWidth + valueWidth + 5
val output = StringBuilder()
// Table header
output.appendLine("".repeat(totalWidth))
output.appendLine("║ %-${keyWidth}s ║ %-${valueWidth}s ║".format("SECRET KEY", "VALUE"))
output.appendLine("".repeat(totalWidth))
// Regular secrets
parseResult.secrets.forEach { (key, value) ->
val displayValue = if (value.length > valueWidth) {
value.take(valueWidth - 3) + "..."
} else {
value
}
output.appendLine("║ %-${keyWidth}s ║ %-${valueWidth}s ║".format(key, displayValue))
}
// Multiline secrets
parseResult.multilineSecrets.forEach { (key, _) ->
output.appendLine("║ %-${keyWidth}s ║ %-${valueWidth}s ║".format(key, "[MULTILINE VALUE]"))
}
// Table footer
output.appendLine("".repeat(totalWidth))
return output.toString()
}
/**
* Validates that all required keystore secrets are present
*/
fun validateKeystoreSecrets(parseResult: ParseResult): List<String> {
val errors = mutableListOf<String>()
val allSecrets = parseResult.allSecrets
// Required ORIGINAL keystore secrets
listOf(
config.originalKeystorePasswordKey,
config.originalKeystoreAliasKey,
config.originalKeystoreAliasPasswordKey
).forEach { key ->
if (!allSecrets.containsKey(key)) {
errors.add("Missing required ORIGINAL keystore secret: $key")
} else if (allSecrets[key].isNullOrBlank()) {
errors.add("Empty value for required ORIGINAL keystore secret: $key")
}
}
// Required UPLOAD keystore secrets
listOf(
config.uploadKeystorePasswordKey,
config.uploadKeystoreAliasKey,
config.uploadKeystoreAliasPasswordKey
).forEach { key ->
if (!allSecrets.containsKey(key)) {
errors.add("Missing required UPLOAD keystore secret: $key")
} else if (allSecrets[key].isNullOrBlank()) {
errors.add("Empty value for required UPLOAD keystore secret: $key")
}
}
return errors
}
}

View File

@ -0,0 +1,598 @@
package org.convention.keystore
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.util.Base64
/**
* Gradle task for updating secrets.env file with base64-encoded keystores
*
* This task implements the functionality specified in KMPPT-57:
* - Updates secrets.env with base64-encoded keystore content
* - Maintains proper heredoc formatting for multiline values
* - Preserves existing environment variables
* - Handles file creation and updates seamlessly
* - Provides base64 encoding functionality
* - Implements multiline value formatting with proper heredoc syntax
* - Includes file merge logic for existing variables
* - Validates output format for GitHub CLI compatibility
*/
@DisableCachingByDefault(because = "Secrets file updates should always run")
abstract class SecretsEnvUpdateTask : BaseKeystoreTask() {
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
abstract val originalKeystoreFile: RegularFileProperty
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
abstract val uploadKeystoreFile: RegularFileProperty
@get:OutputFile
abstract val secretsEnvFile: RegularFileProperty
@get:Input
@get:Optional
abstract val additionalSecrets: MapProperty<String, String>
@get:Input
@get:Optional
abstract val preserveComments: Property<Boolean>
@get:Input
@get:Optional
abstract val createBackup: Property<Boolean>
@get:Input
@get:Optional
abstract val validateOutput: Property<Boolean>
@get:Input
@get:Optional
abstract val base64LineLength: Property<Int>
@get:Input
@get:Optional
abstract val useHeredocFormat: Property<Boolean>
@get:Input
@get:Optional
abstract val heredocDelimiter: Property<String>
init {
description = "Updates secrets.env file with base64-encoded keystores and maintains proper formatting"
// Set default values
preserveComments.convention(true)
createBackup.convention(true)
validateOutput.convention(true)
base64LineLength.convention(76)
useHeredocFormat.convention(true)
heredocDelimiter.convention("EOF")
secretsEnvFile.convention(project.layout.projectDirectory.file("secrets.env"))
additionalSecrets.convention(emptyMap())
}
@TaskAction
fun updateSecretsEnv() {
logInfo("Starting secrets.env file update task")
val secretsFile = secretsEnvFile.asFile.get()
val config = secretsConfig.get()
try {
// Validate inputs first
validateInputs()
// Create backup if requested and file exists
if (createBackup.get() && secretsFile.exists()) {
createBackupFile(secretsFile, config)
}
// Parse existing secrets.env file if it exists
val existingSecrets = if (secretsFile.exists()) {
parseExistingSecretsFile(secretsFile, config)
} else {
logInfo("Creating new secrets.env file")
ParsedSecretsData()
}
// Encode available keystores to base64
val keystoreSecrets = encodeKeystoresToBase64()
// Merge all secrets
val mergedSecrets = mergeSecrets(existingSecrets, keystoreSecrets)
// Ensure output directory exists
secretsFile.parentFile?.mkdirs()
// Write updated secrets.env file
writeSecretsFile(secretsFile, mergedSecrets)
// Validate output if requested
if (validateOutput.get()) {
validateSecretsFile(secretsFile, config)
}
logInfo("Secrets.env file updated successfully")
printUpdateSummary(keystoreSecrets, mergedSecrets)
} catch (e: Exception) {
logError("Failed to update secrets.env file: ${e.message}")
throw e
}
}
/**
* Data class to hold parsed secrets file content
*/
private data class ParsedSecretsData(
val simpleSecrets: MutableMap<String, String> = mutableMapOf(),
val multilineSecrets: MutableMap<String, String> = mutableMapOf(),
val comments: MutableList<String> = mutableListOf(),
val originalOrder: MutableList<String> = mutableListOf(), // Track order of keys
)
/**
* Validates task inputs before execution
*/
private fun validateInputs() {
val originalFile = originalKeystoreFile.orNull?.asFile
val uploadFile = uploadKeystoreFile.orNull?.asFile
// Check if at least one keystore file is provided and exists
val hasValidOriginal = originalFile?.exists() == true
val hasValidUpload = uploadFile?.exists() == true
if (!hasValidOriginal && !hasValidUpload) {
logWarning("No valid keystore files found. Task will only process existing secrets and additional secrets.")
}
// Validate file readability
originalFile?.let { file ->
if (file.exists() && !file.canRead()) {
throw IllegalStateException("Cannot read original keystore file: ${file.absolutePath}")
}
}
uploadFile?.let { file ->
if (file.exists() && !file.canRead()) {
throw IllegalStateException("Cannot read upload keystore file: ${file.absolutePath}")
}
}
// Validate base64 line length
if (base64LineLength.get() < 0) {
throw IllegalArgumentException("Base64 line length cannot be negative")
}
// Validate heredoc delimiter
if (useHeredocFormat.get() && heredocDelimiter.get().isBlank()) {
throw IllegalArgumentException("Heredoc delimiter cannot be blank when heredoc format is enabled")
}
}
/**
* Creates a backup of the existing secrets file
*/
private fun createBackupFile(secretsFile: File, config: SecretsConfig) {
try {
val timestamp = System.currentTimeMillis()
val backupFile = config.getBackupFile(timestamp.toString())
if (!ensureDirectoryExists(backupFile.parentFile)) {
throw IllegalStateException("Failed to create backup directory: ${backupFile.parentFile.absolutePath}")
}
secretsFile.copyTo(backupFile, overwrite = true)
logInfo("Created backup: ${backupFile.absolutePath}")
} catch (e: Exception) {
logWarning("Failed to create backup: ${e.message}")
// Continue execution even if backup fails, unless it's critical
}
}
/**
* Parses existing secrets.env file while preserving structure and comments
*/
private fun parseExistingSecretsFile(secretsFile: File, config: SecretsConfig): ParsedSecretsData {
try {
val parser = SecretsEnvParser(config)
val parseResult = parser.parseFile(secretsFile)
if (!parseResult.isValid) {
logWarning("Issues parsing existing secrets file:")
parseResult.errors.forEach { error -> logWarning(" - $error") }
logWarning("Continuing with partial parsing...")
}
val parsedData = ParsedSecretsData()
// Add simple secrets
parseResult.secrets.forEach { (key, value) ->
parsedData.simpleSecrets[key] = value
parsedData.originalOrder.add(key)
}
// Add multiline secrets
parseResult.multilineSecrets.forEach { (key, value) ->
parsedData.multilineSecrets[key] = value
parsedData.originalOrder.add(key)
}
// Preserve comments if requested
if (preserveComments.get()) {
parsedData.comments.addAll(parseResult.comments)
}
logInfo("Parsed existing secrets file: ${parsedData.simpleSecrets.size} simple + ${parsedData.multilineSecrets.size} multiline secrets")
return parsedData
} catch (e: Exception) {
logError("Failed to parse existing secrets file: ${e.message}")
logWarning("Starting with empty secrets data")
return ParsedSecretsData()
}
}
/**
* Encodes available keystores to base64 format
*/
private fun encodeKeystoresToBase64(): Map<String, String> {
val keystoreSecrets = mutableMapOf<String, String>()
val config = secretsConfig.get()
// Encode original keystore if provided and exists
originalKeystoreFile.orNull?.asFile?.let { file ->
when {
!file.exists() -> {
logInfo("Original keystore file not found: ${file.absolutePath}")
}
file.length() == 0L -> {
logWarning("Original keystore file is empty: ${file.absolutePath}")
}
file.length() > 50 * 1024 * 1024 -> { // 50MB limit
logWarning("Original keystore file is very large (${file.length() / (1024 * 1024)}MB): ${file.absolutePath}")
val base64Content = encodeFileToBase64(file)
keystoreSecrets[config.originalKeystoreFileKey] = base64Content
logInfo("Encoded ORIGINAL keystore: ${file.name}")
}
else -> {
val base64Content = encodeFileToBase64(file)
keystoreSecrets[config.originalKeystoreFileKey] = base64Content
logInfo("Encoded ORIGINAL keystore: ${file.name}")
}
}
}
// Encode upload keystore if provided and exists
uploadKeystoreFile.orNull?.asFile?.let { file ->
when {
!file.exists() -> {
logInfo("Upload keystore file not found: ${file.absolutePath}")
}
file.length() == 0L -> {
logWarning("Upload keystore file is empty: ${file.absolutePath}")
}
file.length() > 50 * 1024 * 1024 -> { // 50MB limit
logWarning("Upload keystore file is very large (${file.length() / (1024 * 1024)}MB): ${file.absolutePath}")
val base64Content = encodeFileToBase64(file)
keystoreSecrets[config.uploadKeystoreFileKey] = base64Content
logInfo("Encoded UPLOAD keystore: ${file.name}")
}
else -> {
val base64Content = encodeFileToBase64(file)
keystoreSecrets[config.uploadKeystoreFileKey] = base64Content
logInfo("Encoded UPLOAD keystore: ${file.name}")
}
}
}
return keystoreSecrets
}
/**
* Encodes a file to base64 with proper line wrapping and error handling
*/
private fun encodeFileToBase64(file: File): String {
try {
val bytes = file.readBytes()
val base64String = Base64.getEncoder().encodeToString(bytes)
return if (base64LineLength.get() > 0) {
// Wrap lines to specified length for better readability
base64String.chunked(base64LineLength.get()).joinToString("\n")
} else {
base64String
}
} catch (e: Exception) {
logError("Failed to encode file to base64: ${file.absolutePath}")
throw IllegalStateException("Failed to encode keystore file: ${e.message}", e)
}
}
/**
* Merges existing secrets with new keystore secrets and additional secrets
*/
private fun mergeSecrets(
existingSecrets: ParsedSecretsData,
keystoreSecrets: Map<String, String>,
): ParsedSecretsData {
val mergedSecrets = ParsedSecretsData()
// Copy existing secrets (preserving order)
existingSecrets.originalOrder.forEach { key ->
when {
existingSecrets.simpleSecrets.containsKey(key) -> {
mergedSecrets.simpleSecrets[key] = existingSecrets.simpleSecrets[key]!!
mergedSecrets.originalOrder.add(key)
}
existingSecrets.multilineSecrets.containsKey(key) -> {
mergedSecrets.multilineSecrets[key] = existingSecrets.multilineSecrets[key]!!
mergedSecrets.originalOrder.add(key)
}
}
}
// Add or update keystore secrets (as multiline)
keystoreSecrets.forEach { (key, value) ->
if (mergedSecrets.originalOrder.contains(key)) {
// Update existing keystore secret
mergedSecrets.multilineSecrets[key] = value
// Remove from simple secrets if it was there before
mergedSecrets.simpleSecrets.remove(key)
logInfo("Updated existing keystore secret: $key")
} else {
// Add new keystore secret
mergedSecrets.multilineSecrets[key] = value
mergedSecrets.originalOrder.add(key)
logInfo("Added new keystore secret: $key")
}
}
// Add additional secrets
additionalSecrets.get().forEach { (key, value) ->
if (!mergedSecrets.originalOrder.contains(key)) {
mergedSecrets.simpleSecrets[key] = value
mergedSecrets.originalOrder.add(key)
logInfo("Added additional secret: $key")
} else {
logWarning("Additional secret '$key' conflicts with existing secret, skipping")
}
}
// Preserve comments
mergedSecrets.comments.addAll(existingSecrets.comments)
return mergedSecrets
}
/**
* Writes the merged secrets to the secrets.env file with proper formatting
*/
private fun writeSecretsFile(
secretsFile: File,
secrets: ParsedSecretsData,
) {
try {
secretsFile.printWriter().use { writer ->
// Write header comment if it's a new file or no comments exist
if (!secretsFile.exists() || secrets.comments.isEmpty()) {
writer.println("# GitHub Secrets Environment File")
writer.println("# Generated by Gradle Keystore Management Plugin")
writer.println("# Format: KEY=VALUE")
writer.println("# Use <<EOF and EOF to denote multiline values")
writer.println("# Run this command to format these secrets: dos2unix secrets.env")
writer.println()
}
// Write preserved comments
if (preserveComments.get() && secrets.comments.isNotEmpty()) {
secrets.comments.forEach { comment ->
writer.println(comment)
}
writer.println()
}
// Write secrets in preserved order
secrets.originalOrder.forEach { key ->
when {
secrets.simpleSecrets.containsKey(key) -> {
val value = secrets.simpleSecrets[key]!!
writer.println("$key=${quoteValueIfNeeded(value)}")
}
secrets.multilineSecrets.containsKey(key) -> {
val value = secrets.multilineSecrets[key]!!
if (useHeredocFormat.get()) {
writer.println("$key<<${heredocDelimiter.get()}")
writer.println(value)
writer.println(heredocDelimiter.get())
} else {
// Fallback to escaped format
val escapedValue = value.replace("\n", "\\n").replace("\"", "\\\"")
writer.println("$key=\"$escapedValue\"")
}
}
}
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to write secrets file: ${secretsFile.absolutePath}", e)
}
}
/**
* Quotes value if it contains spaces or special characters
*/
private fun quoteValueIfNeeded(value: String): String {
return if (value.contains(" ") || value.contains("\t") || value.contains("\n") || value.contains("\"")) {
// Escape quotes in the value
val escapedValue = value.replace("\"", "\\\"")
"\"$escapedValue\""
} else {
value
}
}
/**
* Validates the generated secrets file for GitHub CLI compatibility
*/
private fun validateSecretsFile(secretsFile: File, config: SecretsConfig) {
logInfo("Validating secrets.env file format...")
try {
val parser = SecretsEnvParser(config)
val parseResult = parser.parseFile(secretsFile)
if (parseResult.isValid) {
logInfo("✅ Secrets file validation passed")
// Additional GitHub CLI compatibility checks
val allSecrets = parseResult.allSecrets
val warnings = mutableListOf<String>()
// Check for potential GitHub CLI issues
allSecrets.forEach { (key, value) ->
when {
key.contains(" ") -> warnings.add("Key '$key' contains spaces")
key.contains("-") -> warnings.add("Key '$key' contains hyphens (consider using underscores)")
!key.matches(Regex("[A-Z_][A-Z0-9_]*")) -> warnings.add("Key '$key' doesn't follow UPPER_SNAKE_CASE convention")
value.isEmpty() -> warnings.add("Key '$key' has empty value")
key.length > 100 -> warnings.add("Key '$key' is very long (${key.length} chars)")
value.length > 1024 * 1024 -> warnings.add("Value for '$key' is very large (${value.length / 1024}KB)")
}
}
if (warnings.isNotEmpty()) {
logWarning("GitHub CLI compatibility warnings:")
warnings.forEach { warning -> logWarning(" - $warning") }
} else {
logInfo("✅ No GitHub CLI compatibility issues found")
}
} else {
logError("❌ Secrets file validation failed:")
parseResult.errors.forEach { error -> logError(" - $error") }
throw IllegalStateException("Generated secrets.env file is invalid: ${parseResult.errors.joinToString("/")} ")
}
} catch (e: Exception) {
logError("Failed to validate secrets file: ${e.message}")
throw e
}
}
/**
* Prints a summary of the update operation
*/
private fun printUpdateSummary(keystoreSecrets: Map<String, String>, finalSecrets: ParsedSecretsData) {
logInfo("")
logInfo("=".repeat(66))
logInfo(" UPDATE SUMMARY")
logInfo("=".repeat(66))
if (keystoreSecrets.isNotEmpty()) {
logInfo("Keystore secrets added/updated:")
keystoreSecrets.forEach { (key, _) ->
logInfo("$key")
}
} else {
logInfo("No keystore secrets processed")
}
val totalSecrets = finalSecrets.simpleSecrets.size + finalSecrets.multilineSecrets.size
logInfo("")
logInfo("Total secrets in file: $totalSecrets")
logInfo(" - Simple secrets: ${finalSecrets.simpleSecrets.size}")
logInfo(" - Multiline secrets: ${finalSecrets.multilineSecrets.size}")
if (preserveComments.get() && finalSecrets.comments.isNotEmpty()) {
logInfo(" - Comments preserved: ${finalSecrets.comments.size}")
}
logInfo("")
logInfo("File location: ${secretsEnvFile.asFile.get().absolutePath}")
if (createBackup.get()) {
logInfo("Backup directory: ${secretsConfig.get().backupDir.absolutePath}")
}
logInfo("")
logInfo("✅ Secrets.env file ready for GitHub CLI integration")
}
companion object {
/**
* Creates a task configured to update secrets from keystore generation task
*/
fun createFromKeystoreGeneration(
task: SecretsEnvUpdateTask,
keystoreGenerationTask: KeystoreGenerationTask,
secretsConfig: SecretsConfig = SecretsConfig(),
) {
// Set keystore files from generation task output
val originalKeystoreFile = keystoreGenerationTask.outputDirectory.file(
keystoreGenerationTask.originalConfig.map { it.originalKeystoreName },
)
val uploadKeystoreFile = keystoreGenerationTask.outputDirectory.file(
keystoreGenerationTask.uploadConfig.map { it.uploadKeystoreName },
)
task.originalKeystoreFile.set(originalKeystoreFile)
task.uploadKeystoreFile.set(uploadKeystoreFile)
task.secretsConfig.set(secretsConfig)
// Make this task depend on keystore generation
task.dependsOn(keystoreGenerationTask)
}
/**
* Creates a task with explicit keystore file paths
*/
fun createWithKeystoreFiles(
task: SecretsEnvUpdateTask,
originalKeystoreFile: File?,
uploadKeystoreFile: File?,
secretsConfig: SecretsConfig = SecretsConfig(),
) {
originalKeystoreFile?.let { file ->
task.originalKeystoreFile.set(file)
}
uploadKeystoreFile?.let { file ->
task.uploadKeystoreFile.set(file)
}
task.secretsConfig.set(secretsConfig)
}
/**
* Creates a task with additional environment secrets
*/
fun createWithAdditionalSecrets(
task: SecretsEnvUpdateTask,
additionalSecrets: Map<String, String>,
secretsConfig: SecretsConfig = SecretsConfig(),
) {
task.additionalSecrets.set(additionalSecrets)
task.secretsConfig.set(secretsConfig)
}
}
}

View File

@ -0,0 +1,364 @@
package org.convention.keystore
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
/**
* Unit tests for ConfigurationFileUpdatesTask
*/
class ConfigurationFileUpdatesTaskTest {
@TempDir
lateinit var tempDir: Path
private lateinit var testConfig: KeystoreConfig
private lateinit var keystoreFile: File
@BeforeEach
fun setUp() {
testConfig = KeystoreConfig(
keystorePassword = "test_password",
keyAlias = "test_alias",
keyPassword = "test_key_password",
companyName = "Test Company",
department = "Test Department",
organization = "Test Organization",
city = "Test City",
state = "Test State",
country = "US",
uploadKeystoreName = "test_upload.keystore"
)
// Create a test keystore file
keystoreFile = tempDir.resolve("keystores").resolve("test_upload.keystore").toFile()
keystoreFile.parentFile.mkdirs()
keystoreFile.writeText("test keystore content")
}
@AfterEach
fun tearDown() {
// Cleanup handled by @TempDir
}
@Test
fun `test fastlane config creation with new file`() {
val fastlaneFile = tempDir.resolve("fastlane-config").resolve("android_config.rb").toFile()
// Create task instance (simplified for testing)
val task = TestConfigurationFileUpdatesTask()
// Test creating new fastlane config
task.testCreateNewFastlaneConfig(fastlaneFile, testConfig, "test_upload.keystore")
assertTrue(fastlaneFile.exists(), "Fastlane config file should be created")
val content = fastlaneFile.readText()
assertFastlaneConfigContent(content, testConfig, "test_upload.keystore")
}
@Test
fun `test fastlane config update with existing file`() {
val fastlaneFile = tempDir.resolve("fastlane-config").resolve("android_config.rb").toFile()
fastlaneFile.parentFile.mkdirs()
// Create existing file with old values
val existingContent = """
module FastlaneConfig
module AndroidConfig
STORE_CONFIG = {
default_store_file: "old_keystore.keystore",
default_store_password: "old_password",
default_key_alias: "old_alias",
default_key_password: "old_key_password"
}
FIREBASE_CONFIG = {
firebase_prod_app_id: "existing_app_id",
firebase_demo_app_id: "existing_demo_id",
firebase_service_creds_file: "existing_creds.json",
firebase_groups: "existing_groups"
}
BUILD_PATHS = {
prod_apk_path: "existing/path.apk",
demo_apk_path: "existing/demo.apk",
prod_aab_path: "existing/bundle.aab"
}
end
end
""".trimIndent()
fastlaneFile.writeText(existingContent)
// Create task instance (simplified for testing)
val task = TestConfigurationFileUpdatesTask()
// Test updating existing fastlane config
task.testUpdateExistingFastlaneConfig(fastlaneFile, testConfig, "test_upload.keystore")
val updatedContent = fastlaneFile.readText()
// Verify keystore config was updated
assertTrue(updatedContent.contains("default_store_file: \"test_upload.keystore\""))
assertTrue(updatedContent.contains("default_store_password: \"${testConfig.keystorePassword}\""))
assertTrue(updatedContent.contains("default_key_alias: \"${testConfig.keyAlias}\""))
assertTrue(updatedContent.contains("default_key_password: \"${testConfig.keyPassword}\""))
// Verify other sections were preserved
assertTrue(updatedContent.contains("firebase_prod_app_id: \"existing_app_id\""))
assertTrue(updatedContent.contains("prod_apk_path: \"existing/path.apk\""))
}
@Test
fun `test gradle config update with existing file`() {
val gradleFile = tempDir.resolve("cmp-android").resolve("build.gradle.kts").toFile()
gradleFile.parentFile.mkdirs()
// Create existing gradle file content
val existingContent = """
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "../keystores/old_keystore.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "old_password"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "old_alias"
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD") ?: "old_key_password"
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
""".trimIndent()
gradleFile.writeText(existingContent)
// Create task instance (simplified for testing)
val task = TestConfigurationFileUpdatesTask()
// Test updating gradle config
task.testUpdateGradleConfig(gradleFile, testConfig, keystoreFile)
val updatedContent = gradleFile.readText()
// Verify gradle config was updated with relative path
assertTrue(updatedContent.contains("storeFile = file(System.getenv(\"KEYSTORE_PATH\") ?: \"../keystores/test_upload.keystore\")"))
assertTrue(updatedContent.contains("storePassword = System.getenv(\"KEYSTORE_PASSWORD\") ?: \"${testConfig.keystorePassword}\""))
assertTrue(updatedContent.contains("keyAlias = System.getenv(\"KEYSTORE_ALIAS\") ?: \"${testConfig.keyAlias}\""))
assertTrue(updatedContent.contains("keyPassword = System.getenv(\"KEYSTORE_ALIAS_PASSWORD\") ?: \"${testConfig.keyPassword}\""))
// Verify other parts were preserved
assertTrue(updatedContent.contains("enableV1Signing = true"))
assertTrue(updatedContent.contains("enableV2Signing = true"))
}
@Test
fun `test gradle config update with missing file`() {
val gradleFile = tempDir.resolve("cmp-android").resolve("build.gradle.kts").toFile()
// Create task instance (simplified for testing)
val task = TestConfigurationFileUpdatesTask()
// Test updating non-existent gradle config (should not throw exception)
assertDoesNotThrow {
task.testUpdateGradleConfig(gradleFile, testConfig, keystoreFile)
}
// File should still not exist
assertFalse(gradleFile.exists())
}
@Test
fun `test keystore configuration validation`() {
val invalidConfig = KeystoreConfig(
keystorePassword = "", // Invalid: empty password
keyAlias = "", // Invalid: empty alias
keyPassword = "valid_password",
keySize = 512, // Invalid: too small
validity = -5, // Invalid: negative
country = "USA" // Invalid: not 2 characters
)
val errors = invalidConfig.validate()
assertTrue(errors.contains("Keystore password cannot be blank"))
assertTrue(errors.contains("Key alias cannot be blank"))
assertTrue(errors.contains("Key size must be at least 1024 bits"))
assertTrue(errors.contains("Validity period must be positive"))
assertTrue(errors.contains("Country code must be exactly 2 characters"))
}
@Test
fun `test keystore path resolution`() {
val gradleDir = tempDir.resolve("cmp-android").toFile()
gradleDir.mkdirs()
val task = TestConfigurationFileUpdatesTask()
// Test with keystore file in different location
val keystoreInRootDir = tempDir.resolve("upload.keystore").toFile()
keystoreInRootDir.writeText("test content")
val relativePath = task.testCalculateRelativePath(gradleDir, keystoreInRootDir)
// Should be relative path from cmp-android to root
assertEquals("../upload.keystore", relativePath)
}
@Test
fun `test configuration parsing from secrets`() {
// Create a test secrets.env file
val secretsFile = tempDir.resolve("secrets.env").toFile()
val secretsContent = """
# Test secrets file
UPLOAD_KEYSTORE_FILE_PASSWORD=secret_password
UPLOAD_KEYSTORE_ALIAS=secret_alias
UPLOAD_KEYSTORE_ALIAS_PASSWORD=secret_key_password
COMPANY_NAME=Secret Company
DEPARTMENT=Secret Department
ORGANIZATION=Secret Organization
CITY=Secret City
STATE=Secret State
COUNTRY=US
UPLOAD_KEYSTORE_NAME=secret_upload.keystore
""".trimIndent()
secretsFile.writeText(secretsContent)
val secretsConfig = SecretsConfig(secretsEnvFile = secretsFile)
val parser = SecretsEnvParser(secretsConfig)
val parseResult = parser.parseFile()
assertTrue(parseResult.isValid)
val secrets = parseResult.allSecrets
assertEquals("secret_password", secrets["UPLOAD_KEYSTORE_FILE_PASSWORD"])
assertEquals("secret_alias", secrets["UPLOAD_KEYSTORE_ALIAS"])
assertEquals("secret_key_password", secrets["UPLOAD_KEYSTORE_ALIAS_PASSWORD"])
assertEquals("Secret Company", secrets["COMPANY_NAME"])
assertEquals("secret_upload.keystore", secrets["UPLOAD_KEYSTORE_NAME"])
}
private fun assertFastlaneConfigContent(content: String, config: KeystoreConfig, keystoreName: String) {
assertTrue(content.contains("module FastlaneConfig"))
assertTrue(content.contains("module AndroidConfig"))
assertTrue(content.contains("STORE_CONFIG = {"))
assertTrue(content.contains("default_store_file: \"$keystoreName\""))
assertTrue(content.contains("default_store_password: \"${config.keystorePassword}\""))
assertTrue(content.contains("default_key_alias: \"${config.keyAlias}\""))
assertTrue(content.contains("default_key_password: \"${config.keyPassword}\""))
// Should also contain other required sections
assertTrue(content.contains("FIREBASE_CONFIG = {"))
assertTrue(content.contains("BUILD_PATHS = {"))
}
/**
* Test helper class that exposes private methods for testing
*/
private class TestConfigurationFileUpdatesTask {
fun testCreateNewFastlaneConfig(configFile: File, config: KeystoreConfig, keystoreName: String) {
val content = """
module FastlaneConfig
module AndroidConfig
STORE_CONFIG = {
default_store_file: "$keystoreName",
default_store_password: "${config.keystorePassword}",
default_key_alias: "${config.keyAlias}",
default_key_password: "${config.keyPassword}"
}
FIREBASE_CONFIG = {
firebase_prod_app_id: "1:728433984912738:android:3902eb32kjaska3363b0938f1a1dbb",
firebase_demo_app_id: "1:72843493212738:android:8392hjks3298ak9032skja",
firebase_service_creds_file: "secrets/firebaseAppDistributionServiceCredentialsFile.json",
firebase_groups: "mifos-mobile-apps"
}
BUILD_PATHS = {
prod_apk_path: "cmp-android/build/outputs/apk/prod/release/cmp-android-prod-release.apk",
demo_apk_path: "cmp-android/build/outputs/apk/demo/release/cmp-android-demo-release.apk",
prod_aab_path: "cmp-android/build/outputs/bundle/prodRelease/cmp-android-prod-release.aab"
}
end
end
""".trimIndent()
configFile.parentFile.mkdirs()
configFile.writeText(content)
}
fun testUpdateExistingFastlaneConfig(configFile: File, config: KeystoreConfig, keystoreName: String) {
val content = configFile.readText()
// Use regex to replace the values while preserving file structure
val updatedContent = content
.replace(Regex("default_store_file:\\s*\"[^\"]*\""), "default_store_file: \"$keystoreName\"")
.replace(Regex("default_store_password:\\s*\"[^\"]*\""), "default_store_password: \"${config.keystorePassword}\"")
.replace(Regex("default_key_alias:\\s*\"[^\"]*\""), "default_key_alias: \"${config.keyAlias}\"")
.replace(Regex("default_key_password:\\s*\"[^\"]*\""), "default_key_password: \"${config.keyPassword}\"")
configFile.writeText(updatedContent)
}
fun testUpdateGradleConfig(gradleFile: File, config: KeystoreConfig, keystoreFile: File?) {
// Check if the file exists
if (!gradleFile.exists()) {
// Simulate the warning and return (matches actual implementation)
return
}
// Determine keystore path - use relative path from cmp-android directory
val keystorePath = if (keystoreFile != null) {
val gradleDir = gradleFile.parentFile
val relativePath = gradleDir.toPath().relativize(keystoreFile.toPath()).toString()
relativePath.replace('\\', '/') // Ensure forward slashes for Gradle
} else {
"../keystores/${config.uploadKeystoreName}"
}
// Read the current content
val content = gradleFile.readText()
// Use regex to update the signing configuration lines
val updatedContent = content
.replace(
Regex("storeFile = file\\(System\\.getenv\\(\"KEYSTORE_PATH\"\\) \\?\\: \"[^\"]*\"\\)"),
"storeFile = file(System.getenv(\"KEYSTORE_PATH\") ?: \"$keystorePath\")"
)
.replace(
Regex("storePassword = System\\.getenv\\(\"KEYSTORE_PASSWORD\"\\) \\?\\: \"[^\"]*\""),
"storePassword = System.getenv(\"KEYSTORE_PASSWORD\") ?: \"${config.keystorePassword}\""
)
.replace(
Regex("keyAlias = System\\.getenv\\(\"KEYSTORE_ALIAS\"\\) \\?\\: \"[^\"]*\""),
"keyAlias = System.getenv(\"KEYSTORE_ALIAS\") ?: \"${config.keyAlias}\""
)
.replace(
Regex("keyPassword = System\\.getenv\\(\"KEYSTORE_ALIAS_PASSWORD\"\\) \\?\\: \"[^\"]*\""),
"keyPassword = System.getenv(\"KEYSTORE_ALIAS_PASSWORD\") ?: \"${config.keyPassword}\""
)
gradleFile.writeText(updatedContent)
}
fun testCalculateRelativePath(fromDir: File, toFile: File): String {
val relativePath = fromDir.toPath().relativize(toFile.toPath()).toString()
return relativePath.replace('\\', '/') // Ensure forward slashes for Gradle
}
}
}

View File

@ -0,0 +1,306 @@
package org.convention.keystore
import org.gradle.api.Project
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.io.TempDir
import java.io.File
/**
* Tests for KeystoreGenerationTask
*/
class KeystoreGenerationTaskTest {
@TempDir
lateinit var tempDir: File
private lateinit var project: Project
private lateinit var task: KeystoreGenerationTask
@BeforeEach
fun setup() {
project = ProjectBuilder.builder()
.withProjectDir(tempDir)
.build()
task = project.tasks.register("testGenerateKeystores", KeystoreGenerationTask::class.java).get()
}
@Test
fun `task is created with correct configuration`() {
// Verify task is configured correctly
assertEquals("keystore", task.group)
assertTrue(task.description.contains("Generates Android keystores"))
assertTrue(task.generateOriginal.get())
assertTrue(task.generateUpload.get())
}
@Test
fun `task has correct default configurations`() {
// Verify default configurations
val originalConfig = task.originalConfig.get()
val uploadConfig = task.uploadConfig.get()
// Check ORIGINAL keystore defaults
assertEquals("androiddebugkey", originalConfig.keyAlias)
assertEquals("original.keystore", originalConfig.originalKeystoreName)
assertEquals("Android Debug", originalConfig.companyName)
// Check UPLOAD keystore defaults
assertEquals("upload", uploadConfig.keyAlias)
assertEquals("upload.keystore", uploadConfig.uploadKeystoreName)
assertEquals("Android Release", uploadConfig.companyName)
}
@Test
fun `task creates correct output directory`() {
// Set a custom output directory
val outputDir = File(tempDir, "custom-keystores")
task.outputDirectory.set(outputDir)
assertEquals(outputDir, task.outputDirectory.asFile.get())
}
@Test
fun `task builds correct distinguished name from config`() {
val config = KeystoreConfig(
keystorePassword = "testpass",
keyAlias = "testalias",
keyPassword = "keypass",
keyAlgorithm = "RSA",
keySize = 2048,
validity = 25,
companyName = "Test Company",
department = "Test Dept",
organization = "Test Org",
city = "Test City",
state = "Test State",
country = "US"
)
task.originalConfig.set(config)
// Verify distinguished name construction
val expectedDN = "CN=Test Company, OU=Test Dept, O=Test Org, L=Test City, ST=Test State, C=US"
assertEquals(expectedDN, config.distinguishedName)
assertEquals(config, task.originalConfig.get())
}
@Test
fun `configuration loading from secrets works correctly`() {
// Create a test secrets.env file
val secretsFile = File(tempDir, "secrets.env")
secretsFile.writeText("""
# Test secrets file
ORIGINAL_KEYSTORE_FILE_PASSWORD=original_password
ORIGINAL_KEYSTORE_ALIAS=original_alias
ORIGINAL_KEYSTORE_ALIAS_PASSWORD=original_alias_password
UPLOAD_KEYSTORE_FILE_PASSWORD=upload_password
UPLOAD_KEYSTORE_ALIAS=upload_alias
UPLOAD_KEYSTORE_ALIAS_PASSWORD=upload_alias_password
COMPANY_NAME=Test Company From Secrets
DEPARTMENT=Test Department
ORGANIZATION=Test Organization
CITY=Test City
STATE=Test State
COUNTRY=TS
KEYALG=RSA
KEYSIZE=4096
VALIDITY=30
OVERWRITE=true
""".trimIndent())
// Create SecretsConfig pointing to our test file
val secretsConfig = SecretsConfig(secretsEnvFile = secretsFile)
// Apply configuration from secrets
KeystoreGenerationTask.createWithSecretsConfig(task, secretsConfig)
// Verify configurations were loaded
val originalConfig = task.originalConfig.get()
val uploadConfig = task.uploadConfig.get()
// Check ORIGINAL keystore configuration
assertEquals("original_password", originalConfig.keystorePassword)
assertEquals("original_alias", originalConfig.keyAlias)
assertEquals("original_alias_password", originalConfig.keyPassword)
assertEquals("Test Company From Secrets", originalConfig.companyName)
assertEquals("Test Department", originalConfig.department)
assertEquals("Test Organization", originalConfig.organization)
assertEquals("Test City", originalConfig.city)
assertEquals("Test State", originalConfig.state)
assertEquals("TS", originalConfig.country)
assertEquals("RSA", originalConfig.keyAlgorithm)
assertEquals(4096, originalConfig.keySize)
assertEquals(30, originalConfig.validity)
assertTrue(originalConfig.overwriteExisting)
// Check UPLOAD keystore configuration
assertEquals("upload_password", uploadConfig.keystorePassword)
assertEquals("upload_alias", uploadConfig.keyAlias)
assertEquals("upload_alias_password", uploadConfig.keyPassword)
assertEquals("Test Company From Secrets", uploadConfig.companyName)
assertEquals("Test Department", uploadConfig.department)
assertEquals("Test Organization", uploadConfig.organization)
assertEquals("Test City", uploadConfig.city)
assertEquals("Test State", uploadConfig.state)
assertEquals("TS", uploadConfig.country)
assertEquals("RSA", uploadConfig.keyAlgorithm)
assertEquals(4096, uploadConfig.keySize)
assertEquals(30, uploadConfig.validity)
assertTrue(uploadConfig.overwriteExisting)
}
@Test
fun `task handles missing secrets file gracefully`() {
// Create SecretsConfig pointing to non-existent file
val secretsConfig = SecretsConfig(secretsEnvFile = File(tempDir, "nonexistent.env"))
// Apply configuration from secrets (should not throw)
assertDoesNotThrow {
KeystoreGenerationTask.createWithSecretsConfig(task, secretsConfig)
}
// Should use default configurations
val originalConfig = task.originalConfig.get()
val uploadConfig = task.uploadConfig.get()
assertEquals("Android Debug", originalConfig.companyName)
assertEquals("Android Release", uploadConfig.companyName)
}
@Test
fun `task configuration can be overridden programmatically`() {
// Create custom configurations
val customOriginalConfig = KeystoreConfig(
keystorePassword = "custom_original_pass",
keyAlias = "custom_original_alias",
keyPassword = "custom_original_key_pass",
companyName = "Custom Original Company",
keySize = 4096,
validity = 50
)
val customUploadConfig = KeystoreConfig(
keystorePassword = "custom_upload_pass",
keyAlias = "custom_upload_alias",
keyPassword = "custom_upload_key_pass",
companyName = "Custom Upload Company",
keySize = 4096,
validity = 50
)
// Apply custom configurations
task.originalConfig.set(customOriginalConfig)
task.uploadConfig.set(customUploadConfig)
// Verify configurations were applied
val appliedOriginalConfig = task.originalConfig.get()
val appliedUploadConfig = task.uploadConfig.get()
assertEquals("custom_original_pass", appliedOriginalConfig.keystorePassword)
assertEquals("custom_original_alias", appliedOriginalConfig.keyAlias)
assertEquals("Custom Original Company", appliedOriginalConfig.companyName)
assertEquals(4096, appliedOriginalConfig.keySize)
assertEquals(50, appliedOriginalConfig.validity)
assertEquals("custom_upload_pass", appliedUploadConfig.keystorePassword)
assertEquals("custom_upload_alias", appliedUploadConfig.keyAlias)
assertEquals("Custom Upload Company", appliedUploadConfig.companyName)
assertEquals(4096, appliedUploadConfig.keySize)
assertEquals(50, appliedUploadConfig.validity)
}
@Test
fun `individual keystore generation flags work correctly`() {
// Test generating only ORIGINAL keystore
task.generateOriginal.set(true)
task.generateUpload.set(false)
assertTrue(task.generateOriginal.get())
assertFalse(task.generateUpload.get())
// Test generating only UPLOAD keystore
task.generateOriginal.set(false)
task.generateUpload.set(true)
assertFalse(task.generateOriginal.get())
assertTrue(task.generateUpload.get())
// Test generating both keystores
task.generateOriginal.set(true)
task.generateUpload.set(true)
assertTrue(task.generateOriginal.get())
assertTrue(task.generateUpload.get())
}
@Test
fun `task validates keystore configurations`() {
// Test with valid configuration
val validConfig = KeystoreConfig(
keystorePassword = "validpassword",
keyAlias = "validalias",
keyPassword = "validkeypass",
keySize = 2048,
validity = 25,
country = "US"
)
val validationErrors = validConfig.validate()
assertTrue(validationErrors.isEmpty(), "Valid configuration should have no errors")
// Test with invalid configuration
val invalidConfig = KeystoreConfig(
keystorePassword = "", // Invalid: blank password
keyAlias = "", // Invalid: blank alias
keyPassword = "", // Invalid: blank key password
keySize = 512, // Invalid: too small key size
validity = -1, // Invalid: negative validity
country = "USA" // Invalid: country code too long
)
val invalidErrors = invalidConfig.validate()
assertFalse(invalidErrors.isEmpty(), "Invalid configuration should have errors")
assertTrue(invalidErrors.any { it.contains("password cannot be blank") })
assertTrue(invalidErrors.any { it.contains("alias cannot be blank") })
assertTrue(invalidErrors.any { it.contains("Key size must be at least 1024") })
assertTrue(invalidErrors.any { it.contains("Validity period must be positive") })
assertTrue(invalidErrors.any { it.contains("Country code must be exactly 2 characters") })
}
@Test
fun `distinguished name is formatted correctly`() {
val config = KeystoreConfig(
companyName = "Test Company",
department = "Engineering",
organization = "Test Org",
city = "San Francisco",
state = "California",
country = "US"
)
val expectedDN = "CN=Test Company, OU=Engineering, O=Test Org, L=San Francisco, ST=California, C=US"
assertEquals(expectedDN, config.distinguishedName)
}
@Test
fun `keystore file paths are constructed correctly`() {
val config = KeystoreConfig(
keystoreDir = File(tempDir, "test-keystores"),
originalKeystoreName = "debug.keystore",
uploadKeystoreName = "release.keystore"
)
val expectedOriginalPath = File(File(tempDir, "test-keystores"), "debug.keystore")
val expectedUploadPath = File(File(tempDir, "test-keystores"), "release.keystore")
assertEquals(expectedOriginalPath, config.originalKeystorePath)
assertEquals(expectedUploadPath, config.uploadKeystorePath)
}
}

View File

@ -0,0 +1,389 @@
package org.convention.keystore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/**
* Unit tests for SecretsEnvParser covering edge cases and various file formats
* Tests the parsing logic against the keystore-manager.sh script behavior
*/
class SecretsEnvParserTest {
private lateinit var parser: SecretsEnvParser
private lateinit var config: SecretsConfig
@TempDir
lateinit var tempDir: File
@BeforeEach
fun setup() {
config = SecretsConfig()
parser = SecretsEnvParser(config)
}
@Test
fun `should parse simple key-value pairs`() {
val testFile = createTestFile(
"""
# Simple key-value pairs
KEY1=value1
KEY2=value2
KEY3="quoted value"
KEY4='single quoted'
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("value1", result.secrets["KEY1"])
assertEquals("value2", result.secrets["KEY2"])
assertEquals("quoted value", result.secrets["KEY3"])
assertEquals("single quoted", result.secrets["KEY4"])
}
@Test
fun `should parse multiline heredoc values`() {
val testFile = createTestFile(
"""
# Multiline value test
MULTILINE_KEY<<EOF
line 1
line 2
line 3
EOF
ANOTHER_KEY=simple_value
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("line 1\nline 2\nline 3", result.multilineSecrets["MULTILINE_KEY"])
assertEquals("simple_value", result.secrets["ANOTHER_KEY"])
}
@Test
fun `should handle different heredoc delimiters`() {
val testFile = createTestFile(
"""
KEY1<<END
content 1
END
KEY2<<DELIMITER
content 2
DELIMITER
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("content 1", result.multilineSecrets["KEY1"])
assertEquals("content 2", result.multilineSecrets["KEY2"])
}
@Test
fun `should handle empty multiline values`() {
val testFile = createTestFile(
"""
EMPTY_MULTILINE<<EOF
EOF
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("", result.multilineSecrets["EMPTY_MULTILINE"])
}
@Test
fun `should handle quotes in various formats`() {
val testFile = createTestFile(
"""
UNQUOTED=value
DOUBLE_QUOTED="value with spaces"
SINGLE_QUOTED='value with spaces'
MIXED_QUOTES="value with 'inner' quotes"
ESCAPED="value with \"escaped\" quotes"
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("value", result.secrets["UNQUOTED"])
assertEquals("value with spaces", result.secrets["DOUBLE_QUOTED"])
assertEquals("value with spaces", result.secrets["SINGLE_QUOTED"])
assertEquals("value with 'inner' quotes", result.secrets["MIXED_QUOTES"])
val value = "value with \"escaped\" quotes"
assertEquals(value, result.secrets["ESCAPED"])
}
@Test
fun `should preserve comments when configured`() {
val testFile = createTestFile(
"""
# This is a comment
KEY1=value1
# Another comment
KEY2=value2
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals(2, result.comments.size)
assertTrue(result.comments.contains("# This is a comment"))
assertTrue(result.comments.contains("# Another comment"))
}
@Test
fun `should handle malformed multiline blocks`() {
val testFile = createTestFile(
"""
# Missing delimiter
BAD_KEY<<
some content
# Unterminated block
UNTERMINATED<<EOF
content without end
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertFalse(result.isValid)
assertTrue(result.errors.any { it.contains("Empty delimiter") })
assertTrue(result.errors.any { it.contains("Unterminated multiline block") })
}
@Test
fun `should flag mismatched heredoc terminator`() {
val testFile = createTestFile(
"""
MISMATCHED<<EOF
some content
END
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertFalse(result.isValid)
// Should report unterminated block mentioning the expected delimiter
assertTrue(result.errors.any { it.contains("Unterminated multiline block") && it.contains("EOF") })
}
@Test
fun `should flag stray terminator without start`() {
val testFile = createTestFile(
"""
# A terminator without a corresponding start
END
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertFalse(result.isValid)
assertTrue(result.errors.any { it.contains("Unrecognized line format: END") })
}
@Test
fun `should handle empty and whitespace-only lines`() {
val testFile = createTestFile(
"""
KEY1=value1
KEY2=value2
KEY3=value3
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals(3, result.secrets.size)
}
@Test
fun `should handle special characters in values`() {
val testFile = createTestFile(
"""
SPECIAL_CHARS="value with @#$%^&*()+={}[]|\\:;\"'<>,.?/~"
URL_VALUE="https://example.com/path?param=value&other=123"
BASE64_LIKE="SGVsbG8gV29ybGQ="
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertNotNull(result.secrets["SPECIAL_CHARS"])
assertNotNull(result.secrets["URL_VALUE"])
assertNotNull(result.secrets["BASE64_LIKE"])
}
@Test
fun `should handle real keystore file format`() {
val testFile = createTestFile(
"""
# Keystore configuration
ORIGINAL_KEYSTORE_FILE_PASSWORD=password123
ORIGINAL_KEYSTORE_ALIAS=myalias
ORIGINAL_KEYSTORE_ALIAS_PASSWORD=aliaspass
ORIGINAL_KEYSTORE_FILE<<EOF
MIILEAIBAzCCCroGCSqGSIb3DQEHAaCCCqsEggqnMIIKozCCBcoGCSqGSIb3DQEHAaCCBbsEggW3
MIIFszCCBa8GCyqGSIb3DQEMCgECoIIFQDCCBTwwZgYJKoZIhvcNAQUNMFkwOAYJKoZIhvcNAQUM
EOF
UPLOAD_KEYSTORE_FILE_PASSWORD=password456
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertEquals("password123", result.secrets["ORIGINAL_KEYSTORE_FILE_PASSWORD"])
assertEquals("myalias", result.secrets["ORIGINAL_KEYSTORE_ALIAS"])
assertTrue(result.multilineSecrets["ORIGINAL_KEYSTORE_FILE"]?.contains("MIILEAIBAzCCCroGCSqGSIb3DQEH") == true)
assertEquals("password456", result.secrets["UPLOAD_KEYSTORE_FILE_PASSWORD"])
}
@Test
fun `should validate required keystore secrets`() {
val testFile = createTestFile(
"""
# Missing some required secrets
ORIGINAL_KEYSTORE_FILE_PASSWORD=password
# Missing ORIGINAL_KEYSTORE_ALIAS
ORIGINAL_KEYSTORE_ALIAS_PASSWORD=aliaspass
""".trimIndent(),
)
val result = parser.parseFile(testFile)
val validationErrors = parser.validateKeystoreSecrets(result)
assertTrue(result.isValid) // Parsing is valid
assertFalse(validationErrors.isEmpty()) // But validation fails
assertTrue(validationErrors.any { it.contains("ORIGINAL_KEYSTORE_ALIAS") })
}
@Test
fun `should handle values with equals signs`() {
val testFile = createTestFile(
"""
URL_WITH_PARAMS="https://api.example.com/v1/auth?token=abc123&user=test"
EQUATION="x = y + z"
BASE64_VALUE="YWxnb3JpdGhtPVJTQSZrZXlzaXplPTIwNDg="
""".trimIndent(),
)
val result = parser.parseFile(testFile)
assertTrue(result.isValid)
assertTrue(result.secrets["URL_WITH_PARAMS"]?.contains("token=abc123&user=test") == true)
assertEquals("x = y + z", result.secrets["EQUATION"])
assertNotNull(result.secrets["BASE64_VALUE"])
}
@Test
fun `should format secrets table correctly`() {
val testFile = createTestFile(
"""
SHORT_KEY=value
VERY_LONG_KEY_NAME_THAT_EXCEEDS_NORMAL_LENGTH=short
MULTILINE<<EOF
line1
line2
EOF
""".trimIndent(),
)
val result = parser.parseFile(testFile)
val tableOutput = parser.formatSecretsTable(result)
assertTrue(tableOutput.contains("SHORT_KEY"))
assertTrue(tableOutput.contains("VERY_LONG_KEY_NAME_THAT_EXCEEDS_NORMAL_LENGTH"))
assertTrue(tableOutput.contains("[MULTILINE VALUE]"))
assertTrue(tableOutput.contains(""))
assertTrue(tableOutput.contains(""))
}
@Test
fun `should handle file not found gracefully`() {
val nonExistentFile = File(tempDir, "nonexistent.env")
val result = parser.parseFile(nonExistentFile)
assertFalse(result.isValid)
assertTrue(result.errors.any { it.contains("File not found") })
assertTrue(result.secrets.isEmpty())
assertTrue(result.multilineSecrets.isEmpty())
}
@Test
fun `should handle malformed key-value pairs`() {
val testFile = createTestFile(
"""
VALID_KEY=valid_value
=value_without_key
key_without_value=
just_text_no_equals
=
""".trimIndent(),
)
val result = parser.parseFile(testFile)
// Should parse what it can and report errors for malformed lines
assertTrue(result.secrets.containsKey("VALID_KEY"))
assertTrue(result.secrets.containsKey("key_without_value"))
assertEquals("", result.secrets["key_without_value"])
assertFalse(result.isValid) // Should have errors for malformed lines
}
@Test
fun `should handle nested heredoc delimiters`() {
val testFile = createTestFile(
"""
OUTER_KEY<<OUTER
Some content
INNER_KEY<<INNER
nested content
INNER
More outer content
OUTER
""".trimIndent(),
)
val result = parser.parseFile(testFile)
// The parser should handle this by treating everything between OUTER delimiters as content
assertTrue(result.isValid)
val content = result.multilineSecrets["OUTER_KEY"]
assertNotNull(content)
assertTrue(content?.contains("INNER_KEY<<INNER") == true)
assertTrue(content?.contains("nested content") == true)
}
private fun createTestFile(content: String): File {
val file = File(tempDir, "test-secrets.env")
file.writeText(content)
return file
}
}

View File

@ -0,0 +1,450 @@
package org.convention.keystore
import org.gradle.internal.impldep.org.testng.Assert.assertEquals
import org.gradle.internal.impldep.org.testng.Assert.assertTrue
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
import java.util.Base64
/**
* Test class for SecretsEnvUpdateTask
*
* Tests various update scenarios as required by KMPPT-57:
* - secrets.env file creation and updates
* - Base64 encoding functionality
* - Multiline value formatting with heredoc
* - File merge logic for existing variables
* - Output format validation for GitHub CLI compatibility
*/
class SecretsEnvUpdateTaskTest {
@TempDir
lateinit var tempDir: Path
private lateinit var project: org.gradle.api.Project
private lateinit var task: SecretsEnvUpdateTask
private lateinit var secretsFile: File
private lateinit var originalKeystoreFile: File
private lateinit var uploadKeystoreFile: File
@BeforeEach
fun setup() {
project = ProjectBuilder.builder()
.withProjectDir(tempDir.toFile())
.build()
task = project.tasks.create("testUpdateSecretsEnv", SecretsEnvUpdateTask::class.java)
secretsFile = tempDir.resolve("secrets.env").toFile()
originalKeystoreFile = tempDir.resolve("original.keystore").toFile()
uploadKeystoreFile = tempDir.resolve("upload.keystore").toFile()
// Configure task
task.secretsEnvFile.set(secretsFile)
task.originalKeystoreFile.set(originalKeystoreFile)
task.uploadKeystoreFile.set(uploadKeystoreFile)
task.secretsConfig.set(SecretsConfig())
}
@Test
fun `test creates new secrets file when none exists`() {
// Arrange
createTestKeystoreFiles()
// Act
task.updateSecretsEnv()
// Assert
assertTrue(secretsFile.exists(), "Secrets file should be created")
val content = secretsFile.readText()
assertTrue(content.contains("# GitHub Secrets Environment File"), "Should contain header comment")
assertTrue(content.contains("ORIGINAL_KEYSTORE_FILE<<EOF"), "Should contain original keystore heredoc")
assertTrue(content.contains("UPLOAD_KEYSTORE_FILE<<EOF"), "Should contain upload keystore heredoc")
assertTrue(content.contains("EOF"), "Should contain EOF delimiters")
}
@Test
fun `test updates existing secrets file preserving variables`() {
// Arrange
createTestKeystoreFiles()
createExistingSecretsFile()
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
// Check that existing variables are preserved
assertTrue(content.contains("EXISTING_SIMPLE_VAR=existing_value"), "Should preserve existing simple variable")
assertTrue(content.contains("ANOTHER_VAR=\"value with spaces\""), "Should preserve quoted variable")
// Check that keystore variables are updated
assertTrue(content.contains("ORIGINAL_KEYSTORE_FILE<<EOF"), "Should update original keystore")
assertTrue(content.contains("UPLOAD_KEYSTORE_FILE<<EOF"), "Should update upload keystore")
}
@Test
fun `test preserves comments when updating file`() {
// Arrange
createTestKeystoreFiles()
createSecretsFileWithComments()
task.preserveComments.set(true)
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
assertTrue(content.contains("# This is a custom comment"), "Should preserve custom comments")
assertTrue(content.contains("# Another important comment"), "Should preserve multiple comments")
}
@Test
fun `test base64 encoding produces valid output`() {
// Arrange
createTestKeystoreFiles()
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
// Extract base64 content
val originalB64 = extractBase64Content(content, "ORIGINAL_KEYSTORE_FILE")
val uploadB64 = extractBase64Content(content, "UPLOAD_KEYSTORE_FILE")
// Verify base64 is valid
assertTrue(isValidBase64(originalB64), "Original keystore base64 should be valid")
assertTrue(isValidBase64(uploadB64), "Upload keystore base64 should be valid")
// Verify decoded content matches original
val originalDecoded = Base64.getDecoder().decode(originalB64.replace("\n", ""))
val uploadDecoded = Base64.getDecoder().decode(uploadB64.replace("\n", ""))
assertEquals(originalKeystoreFile.readBytes().toList(), originalDecoded.toList(),
"Decoded original keystore should match original file")
assertEquals(uploadKeystoreFile.readBytes().toList(), uploadDecoded.toList(),
"Decoded upload keystore should match original file")
}
@Test
fun `test heredoc formatting is correct`() {
// Arrange
createTestKeystoreFiles()
task.useHeredocFormat.set(true)
task.heredocDelimiter.set("EOF")
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
val lines = content.lines()
// Find heredoc blocks
val originalStart = lines.indexOfFirst { it == "ORIGINAL_KEYSTORE_FILE<<EOF" }
val uploadStart = lines.indexOfFirst { it == "UPLOAD_KEYSTORE_FILE<<EOF" }
// Find the corresponding EOF markers after each start
val originalEnd = if (originalStart >= 0) {
lines.drop(originalStart + 1).indexOfFirst { it == "EOF" }.let {
if (it >= 0) it + originalStart + 1 else -1
}
} else -1
val uploadEnd = if (uploadStart >= 0) {
lines.drop(uploadStart + 1).indexOfFirst { it == "EOF" }.let {
if (it >= 0) it + uploadStart + 1 else -1
}
} else -1
assertTrue(originalStart >= 0, "Should find original keystore heredoc start")
assertTrue(originalEnd > originalStart, "Should find original keystore heredoc end")
assertTrue(uploadStart >= 0, "Should find upload keystore heredoc start")
assertTrue(uploadEnd > uploadStart, "Should find upload keystore heredoc end")
// Verify content between heredoc markers is base64
val originalB64Lines = lines.subList(originalStart + 1, originalEnd)
assertTrue(originalB64Lines.isNotEmpty(), "Should have base64 content in original heredoc")
assertTrue(originalB64Lines.all { isValidBase64Line(it) }, "All lines in original heredoc should be valid base64")
}
@Test
fun `test line length wrapping for base64 content`() {
// Arrange
createTestKeystoreFiles()
task.base64LineLength.set(50) // Shorter line length for testing
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
val originalB64 = extractBase64Content(content, "ORIGINAL_KEYSTORE_FILE")
val lines = originalB64.split("\n")
// Verify line length wrapping
lines.forEach { line ->
assertTrue(line.length <= 50, "Base64 lines should not exceed configured length: '${line}' (length: ${line.length})")
}
}
@Test
fun `test creates backup when enabled`() {
// Arrange
createTestKeystoreFiles()
createExistingSecretsFile()
task.createBackup.set(true)
// Configure backup directory to be in temp directory
val customSecretsConfig = SecretsConfig(
backupDir = tempDir.resolve("secrets-backup").toFile()
)
task.secretsConfig.set(customSecretsConfig)
// Act
task.updateSecretsEnv()
// Assert
val backupDir = tempDir.resolve("secrets-backup").toFile()
assertTrue(backupDir.exists(), "Backup directory should be created")
assertTrue(backupDir.listFiles()?.any { it.name.startsWith("secrets.env.backup") } == true,
"Backup file should be created")
}
@Test
fun `test validates output format for GitHub CLI compatibility`() {
// Arrange
createTestKeystoreFiles()
task.validateOutput.set(true)
// Act & Assert (should not throw exception)
task.updateSecretsEnv()
// Verify file can be parsed successfully
val parser = SecretsEnvParser(task.secretsConfig.get())
val parseResult = parser.parseFile(secretsFile)
assertTrue(parseResult.isValid, "Generated file should be valid: ${parseResult.errors}")
}
@Test
fun `test handles missing keystore files gracefully`() {
// Arrange - don't create keystore files, just set non-existent paths
task.originalKeystoreFile.set(File(tempDir.toFile(), "nonexistent.keystore"))
task.uploadKeystoreFile.set(File(tempDir.toFile(), "alsoNonexistent.keystore"))
// Act & Assert (should not throw exception)
task.updateSecretsEnv()
// Verify file is still created with proper content
assertTrue(secretsFile.exists(), "Secrets file should still be created")
val content = secretsFile.readText()
assertTrue(content.contains("# GitHub Secrets Environment File"), "Should contain header")
// Should not contain any keystore secrets since files don't exist
assertTrue(!content.contains("ORIGINAL_KEYSTORE_FILE<<EOF"), "Should not contain original keystore")
assertTrue(!content.contains("UPLOAD_KEYSTORE_FILE<<EOF"), "Should not contain upload keystore")
}
@Test
fun `test additional secrets are included`() {
// Arrange
createTestKeystoreFiles()
val additionalSecrets = mapOf(
"CUSTOM_SECRET" to "custom_value",
"ANOTHER_SECRET" to "another_value"
)
task.additionalSecrets.set(additionalSecrets)
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
assertTrue(content.contains("CUSTOM_SECRET=custom_value"), "Should include custom secret")
assertTrue(content.contains("ANOTHER_SECRET=another_value"), "Should include another secret")
}
@Test
fun `test multiline values use proper escaping when heredoc disabled`() {
// Arrange
createTestKeystoreFiles()
task.useHeredocFormat.set(false)
task.heredocDelimiter.set("EOF")
// Verify the property is set correctly before execution
assertEquals(false, task.useHeredocFormat.get(), "useHeredocFormat should be false")
// Act
task.updateSecretsEnv()
// Assert
val content = secretsFile.readText()
// Check for the specific patterns we expect when heredoc is disabled
val hasOriginalHeredoc = content.contains("ORIGINAL_KEYSTORE_FILE<<EOF")
val hasUploadHeredoc = content.contains("UPLOAD_KEYSTORE_FILE<<EOF")
val hasOriginalQuoted = content.contains("ORIGINAL_KEYSTORE_FILE=\"")
val hasUploadQuoted = content.contains("UPLOAD_KEYSTORE_FILE=\"")
// Debug output if test fails
if (hasOriginalHeredoc || hasUploadHeredoc || !hasOriginalQuoted || !hasUploadQuoted) {
println("=== DEBUG: Unexpected content format ===")
println("Has original heredoc: $hasOriginalHeredoc (should be false)")
println("Has upload heredoc: $hasUploadHeredoc (should be false)")
println("Has original quoted: $hasOriginalQuoted (should be true)")
println("Has upload quoted: $hasUploadQuoted (should be true)")
println("Content:")
println(content)
println("=== END DEBUG ===")
}
// Main assertions
assertTrue(!hasOriginalHeredoc, "Should not contain original keystore heredoc start")
assertTrue(!hasUploadHeredoc, "Should not contain upload keystore heredoc start")
assertTrue(hasOriginalQuoted, "Should use quoted format for original keystore")
assertTrue(hasUploadQuoted, "Should use quoted format for upload keystore")
}
@Test
fun `test handles large keystore files appropriately`() {
// Arrange - create a larger test file
val largeContent = "large keystore content ".repeat(1000)
originalKeystoreFile.writeBytes(largeContent.toByteArray())
uploadKeystoreFile.writeBytes("smaller content".toByteArray())
// Act
task.updateSecretsEnv()
// Assert - should still work but log warnings for large files
assertTrue(secretsFile.exists(), "Secrets file should be created")
val content = secretsFile.readText()
assertTrue(content.contains("ORIGINAL_KEYSTORE_FILE<<EOF"), "Should contain original keystore")
assertTrue(content.contains("UPLOAD_KEYSTORE_FILE<<EOF"), "Should contain upload keystore")
}
@Test
fun `test validation detects GitHub CLI compatibility issues`() {
// Arrange
createTestKeystoreFiles()
val problematicSecrets = mapOf(
"secret-with-dash" to "value1",
"secret with space" to "value2",
"lowercaseSecret" to "value3"
)
task.additionalSecrets.set(problematicSecrets)
task.validateOutput.set(true)
// Act & Assert - should complete but log warnings
task.updateSecretsEnv()
// Verify file contains the problematic secrets
val content = secretsFile.readText()
assertTrue(content.contains("secret-with-dash=value1"), "Should include dash secret")
assertTrue(content.contains("secret with space"), "Should include space secret")
assertTrue(content.contains("lowercaseSecret=value3"), "Should include lowercase secret")
}
@Test
fun `test empty keystore files are handled gracefully`() {
// Arrange - create empty keystore files
originalKeystoreFile.writeBytes(byteArrayOf())
uploadKeystoreFile.writeBytes(byteArrayOf())
// Act
task.updateSecretsEnv()
// Assert - should create file but log warnings about empty files
assertTrue(secretsFile.exists(), "Secrets file should be created")
val content = secretsFile.readText()
assertTrue(content.contains("# GitHub Secrets Environment File"), "Should contain header")
// Empty files should not produce keystore secrets
assertTrue(!content.contains("ORIGINAL_KEYSTORE_FILE<<EOF"), "Should not contain original keystore")
assertTrue(!content.contains("UPLOAD_KEYSTORE_FILE<<EOF"), "Should not contain upload keystore")
}
// Helper methods
private fun createTestKeystoreFiles() {
// Create dummy keystore files with some content
originalKeystoreFile.writeBytes("dummy original keystore content".toByteArray())
uploadKeystoreFile.writeBytes("dummy upload keystore content for testing".toByteArray())
}
private fun createExistingSecretsFile() {
secretsFile.writeText("""
# Existing secrets file
EXISTING_SIMPLE_VAR=existing_value
ANOTHER_VAR="value with spaces"
ORIGINAL_KEYSTORE_FILE<<EOF
old_base64_content_here
EOF
UPLOAD_KEYSTORE_FILE<<EOF
old_upload_content_here
EOF
""".trimIndent())
}
private fun createSecretsFileWithComments() {
secretsFile.writeText("""
# This is a custom comment
EXISTING_VAR=value
# Another important comment
ORIGINAL_KEYSTORE_FILE<<EOF
old_content
EOF
""".trimIndent())
}
private fun extractBase64Content(content: String, key: String): String {
val lines = content.lines()
val startIndex = lines.indexOfFirst { it == "$key<<EOF" }
if (startIndex >= 0) {
val endIndex = lines.drop(startIndex + 1).indexOfFirst { it == "EOF" }.let {
if (it >= 0) it + startIndex + 1 else -1
}
if (endIndex > startIndex) {
return lines.subList(startIndex + 1, endIndex).joinToString("\n")
}
}
return ""
}
private fun isValidBase64(content: String): Boolean {
return try {
Base64.getDecoder().decode(content.replace("\n", ""))
true
} catch (e: IllegalArgumentException) {
false
}
}
private fun isValidBase64Line(line: String): Boolean {
return line.matches(Regex("[A-Za-z0-9+/=]*"))
}
@Test
fun `test task property configuration works correctly`() {
// Test that we can set and read task properties correctly
task.useHeredocFormat.set(false)
task.heredocDelimiter.set("CUSTOM_EOF")
task.base64LineLength.set(64)
assertEquals(false, task.useHeredocFormat.get(), "Should be able to set useHeredocFormat to false")
assertEquals("CUSTOM_EOF", task.heredocDelimiter.get(), "Should be able to set custom delimiter")
assertEquals(64, task.base64LineLength.get(), "Should be able to set custom line length")
}
}

View File

@ -1,19 +1,3 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
dependencyResolutionManagement {
repositories {
google()

View File

@ -13,8 +13,8 @@ call :run_gradle_task "check -p build-logic"
call :run_gradle_task "spotlessApply --no-configuration-cache"
call :run_gradle_task "dependencyGuardBaseline"
call :run_gradle_task "detekt"
call :run_gradle_task ":cmp-android:build"
call :run_gradle_task ":cmp-android:updateProdReleaseBadging"
call :run_gradle_task ":mifos-android:build"
call :run_gradle_task ":mifos-android:updateProdReleaseBadging"
echo All checks and tests completed successfully.
exit /b 0

2
ci-prepush.sh Executable file → Normal file
View File

@ -28,6 +28,8 @@ tasks=(
"spotlessApply --no-configuration-cache"
"dependencyGuardBaseline"
"detekt"
":cmp-android:build"
":cmp-android:updateProdReleaseBadging"
)
for task in "${tasks[@]}"; do

View File

@ -5,17 +5,20 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
import org.mifos.mobile.MifosBuildType
import org.mifos.mobile.dynamicVersion
import com.android.build.api.instrumentation.InstrumentationScope
import org.convention.AppBuildType
import org.convention.dynamicVersion
plugins {
alias(libs.plugins.mifos.android.application)
alias(libs.plugins.mifos.android.application.compose)
alias(libs.plugins.mifos.android.application.flavors)
id("com.google.android.gms.oss-licenses-plugin")
id("com.google.devtools.ksp")
alias(libs.plugins.android.application.convention)
alias(libs.plugins.android.application.compose.convention)
alias(libs.plugins.android.application.flavors.convention)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.ksp)
}
val packageNameSpace: String = libs.versions.androidPackageNamespace.get()
@ -33,11 +36,10 @@ android {
signingConfigs {
create("release") {
storeFile =
file(System.getenv("KEYSTORE_PATH") ?: "../keystores/release_keystore.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "mifos1234"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "mifos-mobile"
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD") ?: "mifos1234"
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "../keystores/release_keystore.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "Wizard@123"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "kmp-project-template"
keyPassword = System.getenv("KEYSTORE_ALIAS_PASSWORD") ?: "Wizard@123"
enableV1Signing = true
enableV2Signing = true
}
@ -45,22 +47,17 @@ android {
buildTypes {
debug {
applicationIdSuffix = MifosBuildType.DEBUG.applicationIdSuffix
applicationIdSuffix = AppBuildType.DEBUG.applicationIdSuffix
}
// Disabling proguard for now until
// https://github.com/openMF/mobile-wallet/issues/1815 this issue is resolved
release {
isMinifyEnabled = false
applicationIdSuffix = MifosBuildType.RELEASE.applicationIdSuffix
isShrinkResources = false
isMinifyEnabled = true
applicationIdSuffix = AppBuildType.RELEASE.applicationIdSuffix
isShrinkResources = true
isDebuggable = false
isJniDebuggable = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
@ -81,31 +78,32 @@ android {
}
}
lint {
xmlReport = true
checkDependencies = true
abortOnError = false
// Disable this rule until we ship the libraries to some maven.
disable += "ResourceName"
disable += "MissingTranslation"
disable += "ExtraTranslation"
baseline = File("lint-baseline.xml")
explainIssues = true
htmlReport = true
// TODO:: Workaround for Ktor(3.2.0) R8/ProGuard Issue
androidComponents {
onVariants { variant ->
variant.instrumentation.transformClassesWith(
FieldSkippingClassVisitor.Factory::class.java,
scope = InstrumentationScope.ALL,
) { params ->
params.classes.add("io.ktor.client.plugins.Messages")
}
}
}
}
dependencyGuard {
configuration("demoDebugRuntimeClasspath")
configuration("demoReleaseRuntimeClasspath")
configuration("prodDebugRuntimeClasspath")
configuration("prodReleaseRuntimeClasspath")
}
dependencies {
implementation(projects.cmpShared)
implementation(projects.core.ui)
implementation(projects.coreBase.platform)
implementation(projects.coreBase.ui)
implementation(projects.coreBase.analytics)
implementation(projects.core.ui)
implementation(projects.core.model)
implementation(projects.core.data)
implementation(projects.core.datastore)
implementation(projects.coreBase.ui)
implementation(projects.coreBase.platform)
// Compose
@ -123,6 +121,18 @@ dependencies {
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kermit.koin)
implementation(libs.app.update.ktx)
implementation(libs.app.update)
implementation(libs.coil.kt)
implementation(libs.filekit.core)
implementation(libs.filekit.compose)
implementation(libs.filekit.dialog.compose)
implementation(libs.filekit.coil)
runtimeOnly(libs.androidx.compose.runtime)
debugImplementation(libs.androidx.compose.ui.tooling)
@ -132,12 +142,6 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.test.ext.junit)
implementation(libs.filekit.core)
implementation(libs.filekit.compose)
implementation(libs.filekit.dialog.compose)
testImplementation(kotlin("test"))
testImplementation(libs.koin.test)
testImplementation(libs.koin.test.junit4)
}
@ -146,4 +150,13 @@ dependencyGuard {
modules = true
tree = true
}
}
baselineProfile {
// Don't build on every iteration of a full assemble.
// Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false
// Make use of Dex Layout Optimizations via Startup Profiles
dexLayoutOptimization = true
}

View File

@ -6,7 +6,7 @@
If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/.
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
-->
<issues format="6" by="lint 8.5.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.5.2)" variant="all" version="8.5.2">

View File

@ -1,123 +1,115 @@
package: name='org.mifos.mobile' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
sdkVersion:'26'
targetSdkVersion:'34'
package: name='cmp.android.app' versionCode='1' versionName='2025.7.3-beta.0.0' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
minSdkVersion:'26'
targetSdkVersion:'36'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.CAMERA'
uses-permission: name='android.permission.READ_EXTERNAL_STORAGE' maxSdkVersion='32'
uses-permission: name='android.permission.WRITE_EXTERNAL_STORAGE' maxSdkVersion='32'
uses-permission: name='android.permission.VIBRATE'
uses-permission: name='android.permission.FLASHLIGHT'
uses-permission: name='android.permission.POST_NOTIFICATIONS'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.WAKE_LOCK'
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
uses-permission: name='android.permission.ACCESS_ADSERVICES_ATTRIBUTION'
uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID'
uses-permission: name='org.mifos.mobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'Mifos Mobile'
application-label-af:'Mifos Mobile'
application-label-am:'Mifos Mobile'
application-label-ar:'Mifos Mobile'
application-label-as:'Mifos Mobile'
application-label-az:'Mifos Mobile'
application-label-be:'Mifos Mobile'
application-label-bg:'Mifos Mobile'
application-label-bn:'Mifos Mobile'
application-label-bs:'Mifos Mobile'
application-label-ca:'Mifos Mobile'
application-label-cs:'Mifos Mobile'
application-label-da:'Mifos Mobile'
application-label-de:'Mifos Mobile'
application-label-el:'Mifos Mobile'
application-label-en-AU:'Mifos Mobile'
application-label-en-CA:'Mifos Mobile'
application-label-en-GB:'Mifos Mobile'
application-label-en-IN:'Mifos Mobile'
application-label-en-XC:'Mifos Mobile'
application-label-es:'Mifos Mobile'
application-label-es-US:'Mifos Mobile'
application-label-et:'Mifos Mobile'
application-label-eu:'Mifos Mobile'
application-label-fa:'Mifos Mobile'
application-label-fi:'Mifos Mobile'
application-label-fr:'Mifos Mobile'
application-label-fr-CA:'Mifos Mobile'
application-label-gl:'Mifos Mobile'
application-label-gu:'Mifos Mobile'
application-label-hi:'Mifos Mobile'
application-label-hr:'Mifos Mobile'
application-label-hu:'Mifos Mobile'
application-label-hy:'Mifos Mobile'
application-label-in:'Mifos Mobile'
application-label-is:'Mifos Mobile'
application-label-it:'Mifos Mobile'
application-label-iw:'Mifos Mobile'
application-label-ja:'Mifos Mobile'
application-label-ka:'Mifos Mobile'
application-label-kk:'Mifos Mobile'
application-label-km:'Mifos Mobile'
application-label-kn:'Mifos Mobile'
application-label-ko:'Mifos Mobile'
application-label-ky:'Mifos Mobile'
application-label-lo:'Mifos Mobile'
application-label-lt:'Mifos Mobile'
application-label-lv:'Mifos Mobile'
application-label-mk:'Mifos Mobile'
application-label-ml:'Mifos Mobile'
application-label-mn:'Mifos Mobile'
application-label-mr:'Mifos Mobile'
application-label-ms:'Mifos Mobile'
application-label-my:'Mifos Mobile'
application-label-nb:'Mifos Mobile'
application-label-ne:'Mifos Mobile'
application-label-nl:'Mifos Mobile'
application-label-or:'Mifos Mobile'
application-label-pa:'Mifos Mobile'
application-label-pl:'Mifos Mobile'
application-label-pt:'Mifos Mobile'
application-label-pt-BR:'Mifos Mobile'
application-label-pt-PT:'Mifos Mobile'
application-label-ro:'Mifos Mobile'
application-label-ru:'Mifos Mobile'
application-label-si:'Mifos Mobile'
application-label-sk:'Mifos Mobile'
application-label-sl:'Mifos Mobile'
application-label-sq:'Mifos Mobile'
application-label-sr:'Mifos Mobile'
application-label-sr-Latn:'Mifos Mobile'
application-label-sv:'Mifos Mobile'
application-label-sw:'Mifos Mobile'
application-label-ta:'Mifos Mobile'
application-label-te:'Mifos Mobile'
application-label-th:'Mifos Mobile'
application-label-tl:'Mifos Mobile'
application-label-tr:'Mifos Mobile'
application-label-uk:'Mifos Mobile'
application-label-ur:'Mifos Mobile'
application-label-uz:'Mifos Mobile'
application-label-vi:'Mifos Mobile'
application-label-zh-CN:'Mifos Mobile'
application-label-zh-HK:'Mifos Mobile'
application-label-zh-TW:'Mifos Mobile'
application-label-zu:'Mifos Mobile'
uses-permission: name='cmp.android.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'AndroidApp'
application-label-af:'AndroidApp'
application-label-am:'AndroidApp'
application-label-ar:'AndroidApp'
application-label-as:'AndroidApp'
application-label-az:'AndroidApp'
application-label-be:'AndroidApp'
application-label-bg:'AndroidApp'
application-label-bn:'AndroidApp'
application-label-bs:'AndroidApp'
application-label-ca:'AndroidApp'
application-label-cs:'AndroidApp'
application-label-da:'AndroidApp'
application-label-de:'AndroidApp'
application-label-el:'AndroidApp'
application-label-en-AU:'AndroidApp'
application-label-en-CA:'AndroidApp'
application-label-en-GB:'AndroidApp'
application-label-en-IN:'AndroidApp'
application-label-en-XC:'AndroidApp'
application-label-es:'AndroidApp'
application-label-es-US:'AndroidApp'
application-label-et:'AndroidApp'
application-label-eu:'AndroidApp'
application-label-fa:'AndroidApp'
application-label-fi:'AndroidApp'
application-label-fr:'AndroidApp'
application-label-fr-CA:'AndroidApp'
application-label-gl:'AndroidApp'
application-label-gu:'AndroidApp'
application-label-hi:'AndroidApp'
application-label-hr:'AndroidApp'
application-label-hu:'AndroidApp'
application-label-hy:'AndroidApp'
application-label-in:'AndroidApp'
application-label-is:'AndroidApp'
application-label-it:'AndroidApp'
application-label-iw:'AndroidApp'
application-label-ja:'AndroidApp'
application-label-ka:'AndroidApp'
application-label-kk:'AndroidApp'
application-label-km:'AndroidApp'
application-label-kn:'AndroidApp'
application-label-ko:'AndroidApp'
application-label-ky:'AndroidApp'
application-label-lo:'AndroidApp'
application-label-lt:'AndroidApp'
application-label-lv:'AndroidApp'
application-label-mk:'AndroidApp'
application-label-ml:'AndroidApp'
application-label-mn:'AndroidApp'
application-label-mr:'AndroidApp'
application-label-ms:'AndroidApp'
application-label-my:'AndroidApp'
application-label-nb:'AndroidApp'
application-label-ne:'AndroidApp'
application-label-nl:'AndroidApp'
application-label-or:'AndroidApp'
application-label-pa:'AndroidApp'
application-label-pl:'AndroidApp'
application-label-pt:'AndroidApp'
application-label-pt-BR:'AndroidApp'
application-label-pt-PT:'AndroidApp'
application-label-ro:'AndroidApp'
application-label-ru:'AndroidApp'
application-label-si:'AndroidApp'
application-label-sk:'AndroidApp'
application-label-sl:'AndroidApp'
application-label-sq:'AndroidApp'
application-label-sr:'AndroidApp'
application-label-sr-Latn:'AndroidApp'
application-label-sv:'AndroidApp'
application-label-sw:'AndroidApp'
application-label-ta:'AndroidApp'
application-label-te:'AndroidApp'
application-label-th:'AndroidApp'
application-label-tl:'AndroidApp'
application-label-tr:'AndroidApp'
application-label-uk:'AndroidApp'
application-label-ur:'AndroidApp'
application-label-uz:'AndroidApp'
application-label-vi:'AndroidApp'
application-label-zh-CN:'AndroidApp'
application-label-zh-HK:'AndroidApp'
application-label-zh-TW:'AndroidApp'
application-label-zu:'AndroidApp'
application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Mifos Mobile' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='org.mifospay.MainActivity' label='' icon=''
property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
application: label='AndroidApp' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='cmp.android.app.MainActivity' label='' icon=''
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
uses-feature: name='android.hardware.camera'
uses-feature: name='android.hardware.camera.autofocus'
uses-feature-not-required: name='android.hardware.camera'
uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
uses-feature: name='android.hardware.screen.portrait'
uses-implied-feature: name='android.hardware.screen.portrait' reason='one or more activities have specified a portrait orientation'
main
other-activities
other-receivers

View File

@ -6,6 +6,9 @@
-keep class com.yalantis.ucrop** { *; }
-keep interface com.yalantis.ucrop** { *; }
-keepattributes SourceFile,LineNumberTable # Keep file names and line numbers.
-keep public class * extends java.lang.Exception # Optional: Keep custom exceptions.
# Proguard Kotlin Example https://github.com/Guardsquare/proguard/blob/master/examples/application-kotlin/proguard.pro
-keepattributes *Annotation*
@ -129,7 +132,6 @@
-dontwarn io.ktor.client.network.sockets.SocketTimeoutException
-dontwarn java.lang.management.RuntimeMXBean
-keep class org.mifospay.core.network.services.* { *;}
-keep class de.jensklingenberg.ktorfit.converter.** { *; }
-keep class de.jensklingenberg.ktorfit.** { *; }
-keeppackagenames de.jensklingenberg.ktorfit.*

View File

@ -6,7 +6,7 @@
If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/.
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
@ -16,13 +16,7 @@
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<!--
Firebase automatically adds the AD_ID permission, even though we don't use it. If you use this
permission you must declare how you're using it to Google Play, otherwise the app will be
@ -39,12 +33,12 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MifosSplash">
android:theme="@style/Theme.AppSplash">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.MifosSplash"
android:theme="@style/Theme.AppSplash"
android:windowSoftInputMode="adjustPan|adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -53,21 +47,9 @@
</intent-filter>
</activity>
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.AppCompat.DayNight"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.AppCompat.DayNight"
android:windowSoftInputMode="adjustResize" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
android:value="barcode_ui" />
<provider
android:name="androidx.core.content.FileProvider"
@ -76,30 +58,22 @@
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/fileproviderpath" />
android:resource="@xml/provider_paths" />
</provider>
<!-- Prompt Google Play services to install the backported photo picker module -->
<service android:name="com.google.android.gms.metadata.ModuleDependencies" android:enabled="false" android:exported="false">
<intent-filter>
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
</intent-filter>
<meta-data android:name="photopicker_activity:0:required" android:value="" />
</service>
<!-- Disable Firebase analytics by default. This setting is overwritten for the `prod` flavor -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<!-- Disable collection of AD_ID for all build variants -->
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
<!-- Firebase automatically adds the following property which we don't use so remove it -->
<property
android:name="android.adservices.AD_SERVICES_CONFIG"
tools:node="remove" />
<!-- Enable Firebase analytics for `prod` builds -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
</application>
</manifest>

View File

@ -5,15 +5,21 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import android.app.Application
import cmp.shared.utils.initKoin
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.request.CachePolicy
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.logger.Level
import template.core.base.ui.getDefaultImageLoader
/**
* Android application class.
@ -23,12 +29,24 @@ import org.koin.core.logger.Level
* @constructor Create empty Android app
* @see Application
*/
class AndroidApp : Application() {
class AndroidApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
initKoin {
androidContext(this@AndroidApp) // Provides the Android app context
androidLogger(Level.DEBUG) // Enables Koin's logging for debugging
androidContext(this@AndroidApp)
androidLogger()
}
}
override fun newImageLoader(context: PlatformContext): ImageLoader =
getDefaultImageLoader(context)
.newBuilder()
.diskCachePolicy(CachePolicy.ENABLED)
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizePercent(0.25)
.build()
}
.build()
}

View File

@ -0,0 +1,21 @@
/*
* Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import org.mifos.core.model.DarkThemeConfig
fun DarkThemeConfig.isDarkMode(
isSystemDarkMode: Boolean,
): Boolean =
when (this) {
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemDarkMode
DarkThemeConfig.DARK -> true
DarkThemeConfig.LIGHT -> false
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import android.os.Build
/**
* A boolean property that indicates whether the current build is a dev build.
*/
val isDevBuild: Boolean
get() = BuildConfig.BUILD_TYPE == "debug"
/**
* A string that represents a displayable app version.
*/
val versionData: String
get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
/**
* A string that represents device data.
*/
val deviceData: String get() = "$deviceBrandModel $osInfo $buildInfo"
/**
* A string representing the build flavor or blank if it is the standard configuration.
*/
private val buildFlavorName: String
get() = when (BuildConfig.FLAVOR) {
"demo" -> ""
else -> "-${BuildConfig.FLAVOR}"
}
/**
* A string representing the build type.
*/
private val buildTypeName: String
get() = when (BuildConfig.BUILD_TYPE) {
"debug" -> "dev"
"release" -> "prod"
else -> BuildConfig.BUILD_TYPE
}
/**
* A string representing the device brand and model.
*/
private val deviceBrandModel: String get() = "\uD83D\uDCF1 ${Build.BRAND} ${Build.MODEL}"
/**
* A string representing the operating system information.
*/
private val osInfo: String get() = "\uD83E\uDD16 ${Build.VERSION.RELEASE}@${Build.VERSION.SDK_INT}"
/**
* A string representing the build information.
*/
private val buildInfo: String
get() = "\uD83D\uDCE6 $buildTypeName" +
buildFlavorName.takeUnless { it.isBlank() }?.let { " $it" }.orEmpty()

View File

@ -5,29 +5,32 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import android.content.res.Configuration
import android.graphics.Color
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import cmp.android.app.util.isSystemInDarkModeFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.mifos.mobile.core.model.MifosThemeConfig
import org.mifos.core.model.DarkThemeConfig
@ColorInt
private val SCRIM_COLOR: Int = Color.Transparent.toArgb()
private val SCRIM_COLOR: Int = Color.TRANSPARENT
/**
* Helper method to handle edge-to-edge logic for dark mode.
@ -37,7 +40,7 @@ private val SCRIM_COLOR: Int = Color.Transparent.toArgb()
*/
@Suppress("MaxLineLength")
fun ComponentActivity.setupEdgeToEdge(
appThemeFlow: Flow<MifosThemeConfig>,
appThemeFlow: Flow<DarkThemeConfig>,
) {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
@ -45,18 +48,8 @@ fun ComponentActivity.setupEdgeToEdge(
isSystemInDarkModeFlow(),
appThemeFlow,
) { isSystemDarkMode, appTheme ->
val currentNightMode = AppCompatDelegate.getDefaultNightMode()
if (currentNightMode != appTheme.osValue) {
AppCompatDelegate.setDefaultNightMode(appTheme.osValue)
}
when (appTheme.osValue) {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemDarkMode
AppCompatDelegate.MODE_NIGHT_YES -> true
AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemDarkMode
}
AppCompatDelegate.setDefaultNightMode(appTheme.osValue)
appTheme.isDarkMode(isSystemDarkMode = isSystemDarkMode)
}
.distinctUntilChanged()
.collect { isDarkMode ->
@ -66,10 +59,28 @@ fun ComponentActivity.setupEdgeToEdge(
val style = SystemBarStyle.auto(
darkScrim = SCRIM_COLOR,
lightScrim = SCRIM_COLOR,
detectDarkMode = { isDarkMode },
// Disabling Dark Mode for this app
detectDarkMode = { false },
)
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
}
}
}
}
/**
* Adds a configuration change listener to retrieve whether system is in
* dark theme or not. This will emit current status immediately and then
* will emit changes as needed.
*/
private fun ComponentActivity.isSystemInDarkModeFlow(): Flow<Boolean> =
callbackFlow {
channel.trySend(element = resources.configuration.isSystemInDarkMode)
val listener = Consumer<Configuration> {
channel.trySend(element = it.isSystemInDarkMode)
}
addOnConfigurationChangedListener(listener = listener)
awaitClose { removeOnConfigurationChangedListener(listener = listener) }
}
.distinctUntilChanged()
.conflate()

View File

@ -0,0 +1,15 @@
/*
* Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import android.content.res.Configuration
val Configuration.isSystemInDarkMode
get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES

View File

@ -5,83 +5,117 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.android.app
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.getValue
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cmp.shared.SharedApp
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.ui.utils.ShareUtils
import template.core.base.platform.LocalManagerProvider
import org.mifos.core.data.repository.NetworkMonitor
import org.mifos.core.data.repository.UserDataRepository
import template.core.base.analytics.AnalyticsHelper
import template.core.base.analytics.lifecycleTracker
import template.core.base.platform.update.AppUpdateManager
import template.core.base.platform.update.AppUpdateManagerImpl
import template.core.base.ui.ShareUtils
import java.util.Locale
import kotlin.getValue
/**
* Main activity class.
* This class is used to set the content view of the activity.
* Main activity class. This class is used to set the content view of the
* activity.
*
* @constructor Create empty Main activity
* @see ComponentActivity
*/
@Suppress("UnusedPrivateProperty")
class MainActivity : ComponentActivity() {
/**
* Called when the activity is starting.
* This is where most initialization should go: calling [setContentView(int)] to inflate the activity's UI,
*/
private val userPreferencesRepository: UserPreferencesRepository by inject()
private lateinit var appUpdateManager: AppUpdateManager
private val userPreferencesRepository: UserDataRepository by inject()
private val networkMonitor: NetworkMonitor by inject()
private val analyticsHelper: AnalyticsHelper by inject()
private val lifecycleTracker by lazy { analyticsHelper.lifecycleTracker() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
runBlocking {
val userThemeConfig = userPreferencesRepository.observeDarkThemeConfig.first()
AppCompatDelegate.setDefaultNightMode(userThemeConfig.osValue)
}
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
appUpdateManager = AppUpdateManagerImpl(this)
val darkThemeConfigFlow = userPreferencesRepository.observeDarkThemeConfig
WindowCompat.setDecorFitsSystemWindows(window, false)
setupEdgeToEdge(darkThemeConfigFlow)
ShareUtils.setActivityProvider { return@setActivityProvider this }
FileKit.init(this)
/**
* Set the content view of the activity.
* @see setContent
*/
analyticsHelper.setUserId(deviceData)
setContent {
LocalManagerProvider(context = this) {
SharedApp(
handleThemeMode = {
AppCompatDelegate.setDefaultNightMode(it)
},
handleAppLocale = {
it?.let {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(it),
)
Locale.setDefault(Locale(it))
}
},
onSplashScreenRemoved = {
shouldShowSplashScreen = false
},
)
val status by networkMonitor.isOnline.collectAsStateWithLifecycle(false)
if (status) {
appUpdateManager.checkForAppUpdate()
}
lifecycleTracker.markAppLaunchComplete()
SharedApp(
updateScreenCapture = ::updateScreenCapture,
handleRecreate = ::handleRecreate,
handleThemeMode = {
AppCompatDelegate.setDefaultNightMode(it)
},
handleAppLocale = {
it?.let {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(it),
)
Locale.setDefault(Locale(it))
}
},
onSplashScreenRemoved = {
shouldShowSplashScreen = false
},
)
}
}
override fun onResume() {
super.onResume()
appUpdateManager.checkForResumeUpdateState()
lifecycleTracker.markAppBackground()
}
override fun onStart() {
super.onStart()
lifecycleTracker.markAppLaunchStart()
}
private fun handleRecreate() {
recreate()
}
private fun updateScreenCapture(isScreenCaptureAllowed: Boolean) {
if (isScreenCaptureAllowed) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/.
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="true"
tools:replace="android:value" />
<!-- Enable Firebase analytics for `prod` builds -->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="false"
tools:replace="android:value" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="true"
tools:replace="android:value" />
</application>
</manifest>

View File

@ -17,11 +17,9 @@ plugins {
}
kotlin {
jvm {
withJava()
}
jvm()
jvmToolchain(21)
jvmToolchain(17)
sourceSets {
jvmMain.dependencies {
@ -33,32 +31,19 @@ kotlin {
implementation(libs.kotlin.reflect)
implementation(libs.koin.core)
implementation(compose.components.resources)
}
}
}
val appName: String = libs.versions.packageName.get()
val packageNameSpace: String = libs.versions.packageNamespace.get()
val appVersion: String = libs.versions.packageVersion.get()
val appName: String = libs.versions.desktopPackageName.get()
val packageNameSpace: String = libs.versions.desktopPackageNamespace.get()
val appVersion: String = libs.versions.desktopPackageVersion.get()
compose.desktop {
application {
mainClass = "MainKt"
val buildNumber: String = (project.findProperty("buildNumber") as String?) ?: "1"
val isAppStoreRelease: Boolean =
(project.findProperty("macOsAppStoreRelease") as String?)?.toBoolean() ?: false
nativeDistributions {
targetFormats(
TargetFormat.Pkg,
TargetFormat.Dmg,
TargetFormat.Msi,
TargetFormat.Exe,
TargetFormat.Deb
)
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Deb)
packageName = appName
packageVersion = appVersion
description = "Desktop Application"
@ -66,39 +51,16 @@ compose.desktop {
vendor = "Mifos Initiative"
licenseFile.set(project.file("../LICENSE"))
includeAllModules = true
outputBaseDir.set(project.layout.buildDirectory.dir("release"))
macOS {
bundleID = packageNameSpace
dockName = appName
iconFile.set(project.file("icons/ic_launcher.icns"))
minimumSystemVersion = "12.0"
appStore = isAppStoreRelease
infoPlist {
packageBuildVersion = buildNumber
extraKeysRawXml = """
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
""".trimIndent()
}
if (isAppStoreRelease) {
signing {
sign.set(true)
identity.set("The Mifos Initiative")
}
provisioningProfile.set(project.file("embedded.provisionprofile"))
runtimeProvisioningProfile.set(project.file("runtime.provisionprofile"))
entitlementsFile.set(project.file("entitlements.plist"))
runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist"))
} else {
notarization {
val providers = project.providers
appleID.set(providers.environmentVariable("NOTARIZATION_APPLE_ID"))
password.set(providers.environmentVariable("NOTARIZATION_PASSWORD"))
teamID.set(providers.environmentVariable("NOTARIZATION_TEAM_ID"))
}
notarization {
val providers = project.providers
appleID.set(providers.environmentVariable("NOTARIZATION_APPLE_ID"))
password.set(providers.environmentVariable("NOTARIZATION_PASSWORD"))
teamID.set(providers.environmentVariable("NOTARIZATION_TEAM_ID"))
}
}
@ -116,36 +78,9 @@ compose.desktop {
}
}
buildTypes.release.proguard {
isEnabled = false
// configurationFiles.from(file("compose-desktop.pro"))
// obfuscate.set(true)
// optimize.set(true)
configurationFiles.from(file("compose-desktop.pro"))
obfuscate.set(true)
optimize.set(true)
}
}
}
/**
* Removes the `com.apple.quarantine` extended attribute from the built `.app`.
*
* Why:
* Gatekeeper may mark files from the Internet with `com.apple.quarantine`.
* If any such file ends up inside the `.app`, App Store validation can fail.
*/
val unquarantineApp = tasks.register<Exec>("unquarantineMacApp") {
group = "macOS"
description = "Remove com.apple.quarantine from the built .app before signing"
onlyIf { org.gradle.internal.os.OperatingSystem.current().isMacOsX }
dependsOn("createReleaseDistributable")
val appName = "$appName.app" // set to your final .app name
val appPath = layout.buildDirectory
.dir("release/main-release/app/$appName")
.map { it.asFile.absolutePath }
commandLine("xattr", "-dr", "com.apple.quarantine", appPath.get())
}
tasks.matching { it.name == "packageReleasePkg" }.configureEach {
dependsOn(unquarantineApp)
}

View File

@ -12,10 +12,7 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import cmp.shared.SharedApp
import cmp.shared.generated.resources.Res
import cmp.shared.generated.resources.application_title
import cmp.shared.utils.initKoin
import org.jetbrains.compose.resources.stringResource
/**
* Main function.
@ -43,13 +40,15 @@ fun main() {
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = stringResource(Res.string.application_title),
title = "DesktopApp",
) {
// Sets the content of the window.
SharedApp(
updateScreenCapture = {},
handleRecreate = {},
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
onSplashScreenRemoved = {}
)
}
}

View File

@ -1,3 +1,3 @@
TEAM_ID=L432S2FZP5
BUNDLE_ID=org.mifos.mobile
APP_NAME=Mifos Mobile
BUNDLE_ID=cmp.ios
APP_NAME=LiteDo

View File

@ -1,21 +1,13 @@
deployment_target = '16.0'
source 'https://cdn.cocoapods.org'
platform :ios, '16.0'
use_frameworks!
target 'iosApp' do
use_frameworks!
platform :ios, deployment_target
# Pods for iosApp
project 'iosApp.xcodeproj'
pod 'cmp_shared', :path => '../cmp-shared'
end
post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
end
end
project.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
end
end
# Native Firebase iOS Pods required by firebase-kotlin-sdk
pod 'FirebaseCore'
pod 'FirebaseAnalytics'
pod 'FirebaseCrashlytics'
end

View File

@ -9,22 +9,22 @@
/* Begin PBXBuildFile section */
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
1C3FE2006C77932769810076 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */; };
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
73867D86E599B875F7561EBD /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */; };
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF7B242A565900829871 /* Mifos Mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mifos Mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF7B242A565900829871 /* LiteDo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LiteDo.app; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -32,7 +32,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1C3FE2006C77932769810076 /* Pods_iosApp.framework in Frameworks */,
73867D86E599B875F7561EBD /* Pods_iosApp.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -47,19 +47,10 @@
path = "Preview Content";
sourceTree = "<group>";
};
1826950F674A72E12E219090 /* Pods */ = {
isa = PBXGroup;
children = (
8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */,
1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
42799AB246E5F90AF97AA0EF /* Frameworks */ = {
isa = PBXGroup;
children = (
6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */,
F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -71,14 +62,14 @@
7555FF7D242A565900829871 /* iosApp */,
7555FF7C242A565900829871 /* Products */,
42799AB246E5F90AF97AA0EF /* Frameworks */,
1826950F674A72E12E219090 /* Pods */,
AF5B1102F079B671D1EB1074 /* Pods */,
);
sourceTree = "<group>";
};
7555FF7C242A565900829871 /* Products */ = {
isa = PBXGroup;
children = (
7555FF7B242A565900829871 /* Mifos Mobile.app */,
7555FF7B242A565900829871 /* LiteDo.app */,
);
name = Products;
sourceTree = "<group>";
@ -103,6 +94,15 @@
path = Configuration;
sourceTree = "<group>";
};
AF5B1102F079B671D1EB1074 /* Pods */ = {
isa = PBXGroup;
children = (
B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */,
FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -110,11 +110,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
D005710AF42AFBD3373CBB90 /* [CP] Check Pods Manifest.lock */,
C0BDCDFA5D651BAABC1D1A96 /* [CP] Check Pods Manifest.lock */,
7555FF77242A565900829871 /* Sources */,
B92378962B6B1156000C7307 /* Frameworks */,
7555FF79242A565900829871 /* Resources */,
3843B476B28208558ACE8C15 /* [CP] Copy Pods Resources */,
F3CE6A35EE97CC26402CBE37 /* [CP] Embed Pods Frameworks */,
087C63914A01F15B2F3377F1 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@ -122,7 +123,7 @@
);
name = iosApp;
productName = iosApp;
productReference = 7555FF7B242A565900829871 /* Mifos Mobile.app */;
productReference = 7555FF7B242A565900829871 /* LiteDo.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@ -172,7 +173,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3843B476B28208558ACE8C15 /* [CP] Copy Pods Resources */ = {
087C63914A01F15B2F3377F1 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -189,7 +190,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
showEnvVarsInLog = 0;
};
D005710AF42AFBD3373CBB90 /* [CP] Check Pods Manifest.lock */ = {
C0BDCDFA5D651BAABC1D1A96 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -211,6 +212,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F3CE6A35EE97CC26402CBE37 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -350,7 +368,7 @@
};
7555FFA6242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */;
baseConfigurationReference = B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
@ -363,8 +381,8 @@
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Mifos Mobile";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_CFBundleDisplayName = LiteDo;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -372,6 +390,7 @@
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = org.mifos.kmp.template;
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
@ -381,21 +400,22 @@
};
7555FFA7242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */;
baseConfigurationReference = FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
DEVELOPMENT_TEAM = L432S2FZP5;
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
"$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Mifos Mobile";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_CFBundleDisplayName = LiteDo;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -403,6 +423,7 @@
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = org.mifos.kmp.template;
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;

View File

@ -4,7 +4,7 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>cmp-ios.xcscheme_^#shared#^_</key>
<key>iosApp.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
version = "1.3">
<BuildAction>
<BuildActionEntries>
<BuildActionEntry
buildForRunning = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7555FF7A242A565900829871"
BuildableName = "LiteDo.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<LaunchAction
useCustomWorkingDirectory = "NO"
buildConfiguration = "Debug"
allowLocationSimulation = "YES">
<BuildableProductRunnable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7555FF7A242A565900829871"
BuildableName = "LiteDo.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>iosApp.xcscheme</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict />
</plist>

View File

@ -13,7 +13,8 @@ struct ComposeView: UIViewControllerRepresentable {
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard) // Compose has own keyboard handler
.ignoresSafeArea(edges: .all)
.ignoresSafeArea(.keyboard) // .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}

View File

@ -17,19 +17,13 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<string>11</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We use the camera to scan QR codes for payments and to add beneficiaries. No images or video are stored.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allow access to choose a photo or document you decide to upload (e.g., profile photo or ID).</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow access to choose a photo or document you decide to upload (e.g., profile photo or ID).</string>
<string>Allow access to add photos to your library so you can save artworks directly to your device and view them offline.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -5,30 +5,37 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
plugins {
alias(libs.plugins.kmp.library.convention)
alias(libs.plugins.cmp.feature.convention)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlinCocoapods)
}
kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
optimized = true
}
}
sourceSets {
commonMain.dependencies {
// Navigation Modules
implementation(projects.cmpNavigation)
implementation(compose.components.resources)
api(projects.core.data)
api(projects.core.network)
//put your multiplatform dependencies here
implementation(compose.material)
implementation(compose.material3)
implementation(projects.coreBase.platform)
implementation(projects.coreBase.ui)
implementation(libs.coil.kt.compose)
}
desktopMain.dependencies {
@ -40,7 +47,7 @@ kotlin {
cocoapods {
summary = "KMP Shared Module"
homepage = "https://github.com/openMF/mifos-mobile"
homepage = "https://github.com/openMF/kmp-project-template"
version = "1.0"
ios.deploymentTarget = "16.0"
podfile = project.file("../cmp-ios/Podfile")

View File

@ -1,7 +1,7 @@
Pod::Spec.new do |spec|
spec.name = 'cmp_shared'
spec.version = '1.0'
spec.homepage = 'https://github.com/openMF/mifos-mobile'
spec.homepage = 'https://github.com/openMF/kmp-project-template'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''

View File

@ -5,25 +5,38 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.shared
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cmp.navigation.ComposeApp
import coil3.compose.LocalPlatformContext
import template.core.base.platform.LocalManagerProvider
import template.core.base.platform.context.LocalContext
import template.core.base.ui.LocalImageLoaderProvider
import template.core.base.ui.getDefaultImageLoader
@Composable
fun SharedApp(
updateScreenCapture: (isScreenCaptureAllowed: Boolean) -> Unit,
handleRecreate: () -> Unit,
handleThemeMode: (osValue: Int) -> Unit,
handleAppLocale: (locale: String?) -> Unit,
onSplashScreenRemoved: () -> Unit,
modifier: Modifier = Modifier,
onSplashScreenRemoved: () -> Unit,
) {
ComposeApp(
handleThemeMode = handleThemeMode,
handleAppLocale = handleAppLocale,
onSplashScreenRemoved = onSplashScreenRemoved,
modifier = modifier,
)
LocalManagerProvider(LocalContext.current) {
LocalImageLoaderProvider(getDefaultImageLoader(LocalPlatformContext.current)) {
ComposeApp(
updateScreenCapture = updateScreenCapture,
handleRecreate = handleRecreate,
handleThemeMode = handleThemeMode,
handleAppLocale = handleAppLocale,
onSplashScreenRemoved = onSplashScreenRemoved,
modifier = modifier,
)
}
}
}

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package cmp.shared.utils

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package org.mifos.shared
@ -19,6 +19,8 @@ fun viewController() = ComposeUIViewController(
},
) {
SharedApp(
updateScreenCapture = {},
handleRecreate = {},
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},

View File

@ -8,7 +8,7 @@ plugins {
kotlin {
js(IR) {
moduleName = "cmp-web"
outputModuleName = "cmp-web"
browser {
commonWebpackConfig {
outputFileName = "cmp-web.js"
@ -19,7 +19,7 @@ kotlin {
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
moduleName = "cmp-wasm"
outputModuleName = "cmp-wasm"
browser {
commonWebpackConfig {
outputFileName = "cmp-wasm.js"
@ -51,6 +51,7 @@ kotlin {
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.koin.core)
implementation(libs.ktor.client.js)
}
}
@ -59,10 +60,6 @@ kotlin {
}
}
tasks.register("jsBrowserRun") {
dependsOn("jsBrowserDevelopmentRun")
}
compose.resources {
publicResClass = true
generateResClass = always

View File

@ -21,9 +21,11 @@ fun main() {
onWasmReady {
ComposeViewport(document.body!!) {
SharedApp(
updateScreenCapture = {},
handleRecreate = {},
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
onSplashScreenRemoved = {}
)
}
}

View File

@ -37,7 +37,7 @@ fun main() {
* This window uses the canvas element with the ID "ComposeTarget" and has the title "WebApp".
*/
CanvasBasedWindow(
title = "Mifos Mobile", // Window title
title = "WebApp", // Window title
canvasElementId = "ComposeTarget", // The canvas element where the Compose UI will be rendered
) {
/*
@ -45,9 +45,11 @@ fun main() {
* This function is responsible for setting up the entire UI structure of the app.
*/
SharedApp(
updateScreenCapture = {},
handleRecreate = {},
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
onSplashScreenRemoved = {}
)
}
}

View File

@ -137,7 +137,7 @@ complexity:
threshold: 15
ComplexCondition:
active: true
threshold: 5
threshold: 4
ComplexInterface:
active: false
threshold: 10
@ -146,7 +146,7 @@ complexity:
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 25
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
@ -168,7 +168,7 @@ complexity:
threshold: 600
LongMethod:
active: true
threshold: 180 #60
threshold: 150 #60
LongParameterList:
active: true
# Updating Common values based on current scenario
@ -186,7 +186,7 @@ complexity:
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: true
threshold: 6
threshold: 4
NestedScopeFunctions:
active: false
threshold: 1
@ -232,7 +232,7 @@ complexity:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 22
thresholdInObjects: 20
thresholdInEnums: 20
ignoreDeprecated: false
ignorePrivate: false
@ -432,6 +432,7 @@ naming:
"**/androidInstrumentedTest/**",
"**/jsTest/**",
"**/iosTest/**",
"**/generated/**",
]
functionPattern: "[a-z][a-zA-Z0-9]*"
excludeClassPattern: "$^"
@ -689,7 +690,7 @@ style:
active: false
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 4
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: false
negativeFunctions:

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
plugins {
alias(libs.plugins.kmp.library.convention)

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics.di

View File

@ -6,6 +6,6 @@
If a copy of the MPL was not distributed with this file,
You can obtain one at https://mozilla.org/MPL/2.0/.
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
-->
<manifest />

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
@file:Suppress("InvalidPackageDeclaration")

View File

@ -5,7 +5,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics
@ -255,11 +255,26 @@ object Types {
*
* @since 1.0.0
*/
data class Param(val key: String, val value: String) {
init {
require(key.isNotBlank()) { "Parameter key cannot be blank" }
require(key.length <= 40) { "Parameter key cannot exceed 40 characters" }
require(value.length <= 100) { "Parameter value cannot exceed 100 characters" }
@ConsistentCopyVisibility
data class Param private constructor(
val key: String,
val value: String,
) {
companion object {
private const val MAX_VALUE_LENGTH = 100
private const val MAX_KEY_LENGTH = 40
private const val FALLBACK_KEY = "unknown_param"
operator fun invoke(key: String, value: String): Param {
val safeKey = key
.takeIf { it.isNotBlank() }
?.take(MAX_KEY_LENGTH)
?: FALLBACK_KEY
val safeValue = value.take(MAX_VALUE_LENGTH)
return Param(safeKey, safeValue)
}
}
}

View File

@ -5,11 +5,11 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics
import kotlinx.datetime.Clock
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.DurationUnit

Some files were not shown because too many files have changed in this diff Show More