chore: Sync directories and files from upstream

This commit is contained in:
therajanmaurya 2026-01-19 01:26:09 +00:00 committed by github-actions[bot]
parent 37b6bd9aa3
commit 818ee2adf5
217 changed files with 6617 additions and 1630 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

@ -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

@ -1,21 +1,24 @@
/*
* Copyright 2026 Mifos Initiative
* 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/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

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Mifos Initiative
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/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

@ -1,135 +1,5 @@
-ignorewarnings
# Rules for: uCrop - Image Cropping Library for Android
-dontwarn com.yalantis.ucrop**
-dontwarn java.lang.management.ManagementFactory
-keep class com.yalantis.ucrop** { *; }
-keep interface com.yalantis.ucrop** { *; }
# Proguard Kotlin Example https://github.com/Guardsquare/proguard/blob/master/examples/application-kotlin/proguard.pro
-keepattributes *Annotation*
-keep class kotlin.Metadata { *; }
# Kotlin
-keep class kotlin.reflect.jvm.internal.** { *; }
-keep class kotlin.text.RegexOption { *; }
-keep class kotlin.** { *; }
-keep class org.jetbrains.skia.** { *; }
-keep class org.jetbrains.skiko.** { *; }
-assumenosideeffects public class androidx.compose.runtime.ComposerKt {
void sourceInformation(androidx.compose.runtime.Composer,java.lang.String);
void sourceInformationMarkerStart(androidx.compose.runtime.Composer,int,java.lang.String);
void sourceInformationMarkerEnd(androidx.compose.runtime.Composer);
boolean isTraceInProgress();
void traceEventEnd();
}
# Kotlinx Coroutines Rules
# https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
-keepclassmembers class kotlin.coroutines.SafeContinuation {
volatile <fields>;
}
-dontwarn java.lang.instrument.ClassFileTransformer
-dontwarn sun.misc.SignalHandler
-dontwarn java.lang.instrument.Instrumentation
-dontwarn sun.misc.Signal
-dontwarn java.lang.ClassValue
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
# https://github.com/Kotlin/kotlinx.coroutines/issues/2046
-dontwarn android.annotation.SuppressLint
# https://github.com/JetBrains/compose-jb/issues/2393
-dontnote kotlin.coroutines.jvm.internal.**
-dontnote kotlin.internal.**
-dontnote kotlin.jvm.internal.**
-dontnote kotlin.reflect.**
-dontnote kotlinx.coroutines.debug.internal.**
-dontnote kotlinx.coroutines.internal.**
-keep class kotlin.coroutines.Continuation
-keep class kotlinx.coroutines.CancellableContinuation
-keep class kotlinx.coroutines.channels.Channel
-keep class kotlinx.coroutines.CoroutineDispatcher
-keep class kotlinx.coroutines.CoroutineScope
# this is a weird one, but breaks build on some combinations of OS and JDK (reproduced on Windows 10 + Corretto 16)
-dontwarn org.graalvm.compiler.core.aarch64.AArch64NodeMatchRules_MatchStatementSet*
### kotlinx.serialization rules
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
# Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes
# See also https://github.com/Kotlin/kotlinx.serialization/issues/1900
-dontnote kotlinx.serialization.**
# Serialization core uses `java.lang.ClassValue` for caching inside these specified classes.
# If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning.
# However, since in this case they will not be used, we can disable these warnings
-dontwarn kotlinx.serialization.internal.ClassValueReferences
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keeppackagenames okhttp3.internal.publicsuffix.*
-adaptresourcefilenames okhttp3/internal/publicsuffix/PublicSuffixDatabase.gz
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-keep class io.ktor.** { *; }
-keep class kotlinx.serialization.** { *; }
-keep class io.ktor.client.network.sockets.** { *; }
-keep class io.ktor.client.plugins.* { *; }
-keep class io.ktor.util.* { *; }
-keep class io.ktor.utils.io.* { *; }
-keep class java.lang.management.* { *; }
-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

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Mifos Initiative
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/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,13 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MifosSplash">
android:theme="@style/Theme.AppSplash"
android:localeConfig="@xml/locale_config">
<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 +48,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 +59,36 @@
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" />
<!--
AppCompat automatically stores app-specific locales for Android 12 and lower.
On Android 13+, the system handles locale storage. This service enables
backward compatibility by persisting locale preferences in SharedPreferences.
-->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>

View File

@ -1,19 +1,32 @@
/*
* Copyright 2026 Mifos Initiative
* 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/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 androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.logger.Level
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.mifos.core.data.repository.UserDataRepository
import template.core.base.ui.getDefaultImageLoader
/**
* Android application class.
@ -23,12 +36,59 @@ import org.koin.core.logger.Level
* @constructor Create empty Android app
* @see Application
*/
class AndroidApp : Application() {
class AndroidApp : Application(), SingletonImageLoader.Factory, KoinComponent {
private val userDataRepository: UserDataRepository by inject()
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()
}
// Restore the user's saved language preference to AppCompatDelegate.
// This ensures the app always launches with the user's chosen language,
// regardless of system settings or device language.
restoreSavedLanguage()
}
/**
* Restores the user's saved language preference from the repository to AppCompatDelegate.
*
* This runs BEFORE any Activities are created, ensuring the app launches with the
* correct language. The app's saved preference always takes precedence.
*/
private fun restoreSavedLanguage() {
runBlocking {
val userData = userDataRepository.userData.first()
val savedLanguage = userData.appLanguage
// Convert the saved LanguageConfig to LocaleListCompat
val desiredLocales = if (savedLanguage.localeName != null) {
LocaleListCompat.forLanguageTags(savedLanguage.localeName)
} else {
// System default
LocaleListCompat.getEmptyLocaleList()
}
// Only update if the current locale differs from saved preference
val currentLocales = AppCompatDelegate.getApplicationLocales()
if (currentLocales != desiredLocales) {
AppCompatDelegate.setApplicationLocales(desiredLocales)
}
}
}
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

@ -1,33 +1,36 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

@ -1,93 +1,139 @@
/*
* Copyright 2026 Mifos Initiative
* 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/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.Resources
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
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 AppCompatActivity
* @see ComponentActivity
*/
@Suppress("UnusedPrivateProperty")
class MainActivity : AppCompatActivity() {
/**
* 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 = {
if (it.isNullOrBlank()) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.getEmptyLocaleList(),
)
} else {
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 = { localeTag ->
val currentLocales = AppCompatDelegate.getApplicationLocales()
val newLocales = if (localeTag != null) {
LocaleListCompat.forLanguageTags(localeTag)
} else {
// System Default: clear app-specific locale
LocaleListCompat.getEmptyLocaleList()
}
// Only update if the locale has actually changed
if (currentLocales != newLocales) {
AppCompatDelegate.setApplicationLocales(newLocales)
// Update Locale.setDefault for non-UI formatting
if (localeTag != null) {
// Use forLanguageTag to properly parse locales like "en-GB", "pt-BR"
Locale.setDefault(Locale.forLanguageTag(localeTag))
} else {
// Reset to true system default locale from device configuration
// Use Resources.getSystem() to get device locale unaffected by app overrides
val systemLocale = Resources.getSystem().configuration.locales[0]
Locale.setDefault(systemLocale)
}
}
},
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

@ -8,14 +8,17 @@
* See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
*/
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
import java.util.Locale
/**
* Main function.
@ -39,18 +42,47 @@ fun main() {
// Creates a window state to manage the window's state.
val windowState = rememberWindowState()
// State to trigger recomposition when locale changes
var localeVersion by remember { mutableStateOf(0) }
// Creates a window with a specified title and close request handler.
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = stringResource(Res.string.application_title),
title = "DesktopApp",
) {
// Sets the content of the window.
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
// Use key() to force complete recomposition when locale changes
key(localeVersion) {
// Sets the content of the window.
SharedApp(
updateScreenCapture = {},
handleRecreate = {
// Increment version to trigger recomposition
localeVersion++
},
handleThemeMode = {},
handleAppLocale = { languageTag ->
if (languageTag != null) {
// Parse language tag and set as default locale
val locale = when {
languageTag.contains("-") -> {
val parts = languageTag.split("-")
Locale(parts[0], parts[1])
}
else -> Locale(languageTag)
}
Locale.setDefault(locale)
} else {
// System Default: reset to system locale
val systemLocale = Locale.getDefault(Locale.Category.DISPLAY)
Locale.setDefault(systemLocale)
}
// Trigger recomposition with new locale
localeVersion++
},
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>27</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

@ -1,34 +1,41 @@
/*
* Copyright 2026 Mifos Initiative
* 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/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

@ -1,29 +1,42 @@
/*
* Copyright 2026 Mifos Initiative
* 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/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

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

@ -1,17 +1,24 @@
/*
* Copyright 2026 Mifos Initiative
* 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/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package org.mifos.shared
import androidx.compose.ui.window.ComposeUIViewController
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
import platform.Foundation.NSUserDefaults
import platform.QuartzCore.CALayer
import platform.UIKit.UIApplication
import platform.UIKit.UITextField
import platform.UIKit.UIUserInterfaceStyle
private var secureTextField: UITextField? = null
fun viewController() = ComposeUIViewController(
configure = {
@ -19,8 +26,45 @@ fun viewController() = ComposeUIViewController(
},
) {
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
updateScreenCapture = { isScreenCaptureAllowed ->
UIApplication.sharedApplication.keyWindow?.let { window ->
if (!isScreenCaptureAllowed) {
// Create secure text field to prevent screen capture/recording
if (secureTextField == null) {
val textField = UITextField()
textField.setSecureTextEntry(true)
textField.setUserInteractionEnabled(false)
window.addSubview(textField)
(textField.layer.sublayers?.firstOrNull() as? CALayer)?.let { secureLayer ->
window.layer.superlayer?.addSublayer(secureLayer)
}
secureTextField = textField
}
} else {
secureTextField?.removeFromSuperview()
secureTextField = null
}
}
},
handleRecreate = {},
handleThemeMode = { osValue ->
val style = when (osValue) {
1 -> UIUserInterfaceStyle.UIUserInterfaceStyleLight
2 -> UIUserInterfaceStyle.UIUserInterfaceStyleDark
else -> UIUserInterfaceStyle.UIUserInterfaceStyleUnspecified
}
UIApplication.sharedApplication.keyWindow?.overrideUserInterfaceStyle = style
},
handleAppLocale = { languageTag ->
if (languageTag != null) {
// Set specific language
NSUserDefaults.standardUserDefaults.setObject(listOf(languageTag), forKey = "AppleLanguages")
} else {
// System Default: remove app-specific language setting
NSUserDefaults.standardUserDefaults.removeObjectForKey("AppleLanguages")
}
NSUserDefaults.standardUserDefaults.synchronize()
},
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

@ -1,8 +1,15 @@
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
import kotlinx.browser.document
import kotlinx.browser.localStorage
import kotlinx.browser.window
import org.jetbrains.skiko.wasm.onWasmReady
/*
@ -18,13 +25,46 @@ fun main() {
initKoin() // Set up Koin for dependency injection.
// Apply stored language preference on startup
val storedLanguage = localStorage.getItem("app_language")
if (storedLanguage != null) {
document.documentElement?.setAttribute("lang", storedLanguage)
}
onWasmReady {
ComposeViewport(document.body!!) {
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
// State to trigger recomposition when locale changes
var localeVersion by remember { mutableStateOf(0) }
// Use key() to force complete recomposition when locale changes
key(localeVersion) {
SharedApp(
updateScreenCapture = {},
handleRecreate = {
// Reload the page to apply locale changes
window.location.reload()
},
handleThemeMode = {},
handleAppLocale = { languageTag ->
if (languageTag != null) {
// Store language preference in localStorage
localStorage.setItem("app_language", languageTag)
// Set HTML lang attribute for accessibility
document.documentElement?.setAttribute("lang", languageTag)
} else {
// System Default: remove stored language preference
localStorage.removeItem("app_language")
// Reset to browser's default language
val browserLang = window.navigator.language
document.documentElement?.setAttribute("lang", browserLang)
}
// Reload page to apply language changes (required for web)
// Note: This will reload the page, and locale selection depends on browser settings
// window.location.reload()
},
onSplashScreenRemoved = {}
)
}
}
}
}

View File

@ -1,8 +1,19 @@
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
import androidx.compose.ui.window.ComposeViewport
import androidx.compose.ui.window.ComposeViewportConfiguration
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
import kotlinx.browser.document
import kotlinx.browser.localStorage
import kotlinx.browser.window
import org.jetbrains.compose.resources.configureWebResources
import org.jetbrains.skiko.wasm.onWasmReady
/**
* Main function.
@ -24,6 +35,12 @@ fun main() {
*/
initKoin()
// Apply stored language preference on startup
val storedLanguage = localStorage.getItem("app_language")
if (storedLanguage != null) {
document.documentElement?.setAttribute("lang", storedLanguage)
}
/*
* Configures the web resources for the application.
* Specifically, it sets a path mapping for resources (e.g., CSS, JS).
@ -36,18 +53,44 @@ fun main() {
* Creates a Canvas-based window for rendering the Compose UI.
* This window uses the canvas element with the ID "ComposeTarget" and has the title "WebApp".
*/
CanvasBasedWindow(
title = "Mifos Mobile", // Window title
canvasElementId = "ComposeTarget", // The canvas element where the Compose UI will be rendered
) {
/*
* Invokes the root composable of the application.
* This function is responsible for setting up the entire UI structure of the app.
*/
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
onWasmReady {
ComposeViewport(document.body!!) {
// State to trigger recomposition when locale changes
var localeVersion by remember { mutableStateOf(0) }
// Use key() to force complete recomposition when locale changes
key(localeVersion) {
/*
* Invokes the root composable of the application.
* This function is responsible for setting up the entire UI structure of the app.
*/
SharedApp(
updateScreenCapture = {},
handleRecreate = {
// Reload the page to apply locale changes
window.location.reload()
},
handleThemeMode = {},
handleAppLocale = { languageTag ->
if (languageTag != null) {
// Store language preference in localStorage
localStorage.setItem("app_language", languageTag)
// Set HTML lang attribute for accessibility
document.documentElement?.setAttribute("lang", languageTag)
} else {
// System Default: remove stored language preference
localStorage.removeItem("app_language")
// Reset to browser's default language
val browserLang = window.navigator.language
document.documentElement?.setAttribute("lang", browserLang)
}
// Reload page to apply language changes (required for web)
// Note: This will reload the page, and locale selection depends on browser settings
// window.location.reload()
},
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

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Mifos Initiative
Copyright 2023 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/mobile-mobile/blob/master/LICENSE.md
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
-->
<manifest />

View File

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* Copyright 2023 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/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

@ -1,15 +1,15 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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

View File

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* Copyright 2023 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/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics

View File

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* Copyright 2023 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/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics

View File

@ -1,15 +1,15 @@
/*
* Copyright 2026 Mifos Initiative
* 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 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
/** Performance tracking utilities for analytics */

View File

@ -1,11 +1,11 @@
/*
* Copyright 2026 Mifos Initiative
* Copyright 2023 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/mobile-mobile/blob/master/LICENSE.md
* See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.analytics

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