mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
chore: Sync directories and files from upstream
This commit is contained in:
parent
37b6bd9aa3
commit
818ee2adf5
65
.github/workflows/build-and-deploy-site.yml
vendored
Normal file
65
.github/workflows/build-and-deploy-site.yml
vendored
Normal 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
15
.github/workflows/cache-cleanup.yaml
vendored
Normal 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 }}
|
||||
66
.github/workflows/monthly-version-tag.yml
vendored
Normal file
66
.github/workflows/monthly-version-tag.yml
vendored
Normal 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
|
||||
@ -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 }}
|
||||
|
||||
32
.github/workflows/pr-check.yml
vendored
32
.github/workflows/pr-check.yml
vendored
@ -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'
|
||||
|
||||
4
.github/workflows/promote-to-production.yml
vendored
4
.github/workflows/promote-to-production.yml
vendored
@ -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 }}
|
||||
|
||||
140
.github/workflows/sync-dirs.yaml
vendored
140
.github/workflows/sync-dirs.yaml
vendored
@ -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
|
||||
|
||||
88
.github/workflows/tag-weekly-release.yml
vendored
88
.github/workflows/tag-weekly-release.yml
vendored
@ -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
74
.run/cmp-android.run.xml
Normal 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>
|
||||
@ -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" />
|
||||
|
||||
@ -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
10
Gemfile
@ -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)
|
||||
|
||||
148
Gemfile.lock
148
Gemfile.lock
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
// }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
build-logic/convention/src/main/kotlin/org/convention/Badging.kt
Normal file
156
build-logic/convention/src/main/kotlin/org/convention/Badging.kt
Normal 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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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("-", ""))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
134
build-logic/convention/src/main/kotlin/org/convention/Jacoco.kt
Normal file
134
build-logic/convention/src/main/kotlin/org/convention/Jacoco.kt
Normal 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.*")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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"), "(<[^!?])")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
2
ci-prepush.sh
Executable file → Normal file
@ -28,6 +28,8 @@ tasks=(
|
||||
"spotlessApply --no-configuration-cache"
|
||||
"dependencyGuardBaseline"
|
||||
"detekt"
|
||||
":cmp-android:build"
|
||||
":cmp-android:updateProdReleaseBadging"
|
||||
)
|
||||
|
||||
for task in "${tasks[@]}"; do
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
130
cmp-android/proguard-rules.pro
vendored
130
cmp-android/proguard-rules.pro
vendored
@ -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.*
|
||||
@ -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>
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
cmp-android/src/prod/AndroidManifest.xml
Normal file
33
cmp-android/src/prod/AndroidManifest.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
TEAM_ID=L432S2FZP5
|
||||
BUNDLE_ID=org.mifos.mobile
|
||||
APP_NAME=Mifos Mobile
|
||||
BUNDLE_ID=cmp.ios
|
||||
APP_NAME=LiteDo
|
||||
@ -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
|
||||
@ -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;
|
||||
|
||||
Binary file not shown.
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 />
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user