DEV Community

Cover image for Implementing SCA tools in large legacy Android Project
Muhamad Wahyudin
Muhamad Wahyudin

Posted on

Implementing SCA tools in large legacy Android Project

Overview

Static code analysis (SCA) embodies the "shift left" principle by moving quality control earlier in the development lifecycle. The implementation of pre-commit hooks in our project will automate formatting and quality checks at the commit stage-the earliest possible point-rather than during later review or testing phases.

Challenges

A large legacy Android project with multiple development squad typically faces several key challenges that impact code quality and development efficiency:

  • Team Structure: Large team consist of several squad, could easily lead to inconsistent coding styles and practices
  • Mixed Architecture: Ongoing transition from legacy Architecture to newer architecture, creating dual coding paradigms within the codebase
  • Inconsistent Standards: Variable adherence to code conventions across teams, resulting in style inconsistencies
  • Inefficient Reviews: Significant code review time spent on trivial formatting issues rather than substantive code improvements
  • Growing Technical Debt: Accumulation of preventable quality issues that could be addressed through automated analysis These challenges collectively slow development velocity, reduce code maintainability, and increase the cost of future development efforts.

The Plan

Pre-Commit Hooks

We will implement pre-commit hooks that automatically run before code is committed to our repository. These hooks will:

  1. Focus only on new or modified code to minimize disruption
  2. Format Kotlin code using ktlint
  3. Analyze code quality using detekt
  4. Give feedbacks for comitted code that do not meet our defined standards (Can be configured to prevent commits)

The git pre-commit hook, protects against committing code containing lint violations. From the perspective of code reviewing it is important that the code is already formatted in the style of the project.

Incremental Approach

To avoid disrupting ongoing development, our implementation will:

  • Only analyze new and modified code
  • Apply a threshold limit (e.g., files with fewer than X lines of code)
  • Gradually improve code quality without requiring a complete codebase overhaul

Extensible Architecture

The pre-commit system will be designed with extensibility in mind:

  • Each tool (ktlint, detekt, etc.) will have its own independent shell script
  • A primary pre-commit hook will orchestrate these individual scripts
  • New analysis tools can be added with minimal changes to the core system

Implementation Details

Tool Selection

ktlint (Docs)

ktlint  will enforce consistent Kotlin code formatting based on the official Kotlin style guide, addressing issues such as:

  • Indentation and spacing
  • Line wrapping and line length
  • Naming conventions
  • Import ordering

We'll use kotlinter gradle plugin to add ktlint to our project

detekt (Docs)

detekt  will perform deeper code analysis to identify:

  • Code smells
  • Complexity issues
  • Potential bugs
  • Performance concerns
  • Architecture violations

Pre-Commit Hook Architecture

How It Works

  1. Main Pre-Commit Script (scripts/pre-commit.sh):
    • Controls the entire pre-commit checking process
    • Runs each code analysis tool one after another
    • Decides if the commit can proceed based on tool results
    • Installed automatically through Gradle
  2. File Selection Script (scripts/get-staged-files.sh):
    • Finds Kotlin files that have been changed
    • Filters out files with too many changes
    • Ensures consistent file selection for all tools
  3. Tool-Specific Scripts (scripts/tools/):
    • Separate scripts for each tool (e.g., run_ktlint.sh , run_detekt.sh )
    • Uses file selection script to choose which files to check
    • Reports if files pass or fail the checks
  4. Configuration Files:
    • .editorconfig: Defines code formatting rules (ktlint)
    • .detekt/config.yml: Sets up code quality analysis (detekt)
    • Follows standard locations for easy finding
  5. Gradle Setup (enablePreCommit task):
    • Easy one-command setup for developers
    • Automatically installs the pre-commit hook
    • Works the same on all developer machines

Advantages

  • Easy to add new code analysis tools
  • Consistent file checking across tools
  • Simple to understand configuration
  • Quick setup for new team members
  • All scripts are tracked in the project repository
┌───────────────────────────────────────┐
│                                       │
│  Project Repository                   │
│  ├── .git/                            │
│  │   └── hooks/                       │
│  │       └── pre-commit  ◄────────┐   │
│  │                                │   │
│  ├── .editorconfig                │   │
│  ├── .detekt/                     │   │
│  │   └── config.yml               │   │
│  │                                │   │
│  ├── scripts/                     │   │
│  │   ├── pre-commit.sh  ──────────┘   │
│  │   │   (moved by Gradle task)       │
│  │   │                                │
│  │   ├── get-staged-files.sh          │
│  │   │                                │
│  │   └── tools/                       │
│  │       ├── run_ktlint.sh (format)   │
│  │       ├── run_detekt.sh            │
│  │       └── run_future_tool.sh       │
│  │                                    │
│  ├── build.gradle.kts                 │
│  │   (contains enablePreCommit task)  │
│  │                                    │
│  └── [project files]                  │
│                                       │
└───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Flow:

  • Developer runs Gradle task: ./gradlew enablePreCommit (only once)
  • Task copies scripts/pre-commit.sh to .git/hooks/pre-commit (only once)
  • When developer commits, .git/hooks/pre-commit executes
  • pre-commit calls individual tool scripts in scripts/tools/
  • Each tool script uses scripts/get-staged-files.sh to identify relevant files to analyze

The Codes

Config files

.detekt/config.yml

build:
    maxIssues: 0
    excludeCorrectable: true
    weights:
        comments: 0

config:
    validation: true
    warningsAsErrors: false
    checkExhaustiveness: false

coroutines:
    active: true
    RedundantSuspendModifier:
        active: true
    SleepInsteadOfDelay:
        active: true
    SuspendFunWithFlowReturnType:
        active: true

exceptions:
    active: true
    PrintStackTrace:
        active: true


formatting:
    active: true
    android: true
    autoCorrect: true
    AnnotationOnSeparateLine:
        active: true
        autoCorrect: true

style:
    active: true
    ReturnCount:
        active: true
        max: 4
    MaxLineLength:
        active: true
        severity: warning
        maxLineLength: 120
        excludePackageStatements: true
        excludeImportStatements: true
        excludeCommentStatements: true
    MagicNumber:
        active: true
        ignoreNumbers: [ '-1', '0', '1', '2', '100' , '00' ]
        ignoreHashCodeFunction: true
        ignorePropertyDeclaration: true
        ignoreLocalVariableDeclaration: false
        ignoreConstantDeclaration: true
        ignoreCompanionObjectPropertyDeclaration: true
        ignoreAnnotation: false
        ignoreNamedArgument: true
        ignoreEnums: false
        ignoreRanges: false
        ignoreExtensionFunctions: true
    AlsoCouldBeApply:
        active: true
    CollapsibleIfStatements:
        active: true
    UnnecessaryAnnotationUseSiteTarget:
        active: true
    UnnecessaryLet:
        active: true
    UseIfEmptyOrIfBlank:
        active: true
    UseIfInsteadOfWhen:
        active: true
    UseLet:
        active: true

complexity:
    active: true
    TooManyFunctions:
        active: false
        thresholdInFiles: 11
        thresholdInClasses: 11
        thresholdInInterfaces: 11
        thresholdInObjects: 11
        thresholdInEnums: 11
        ignorePrivate: false
        ignoreOverridden: false

naming:
    active: true
    InvalidPackageDeclaration:
        active: true
    NoNameShadowing:
        active: true


potential-bugs:
    active: true
    CastToNullableType:
        active: true
    EqualsAlwaysReturnsTrueOrFalse:
        active: true
    LateinitUsage:
        active: true
        ignoreAnnotated:
            - Inject
    NullableToStringCall:
        active: true
    UnnecessaryNotNullOperator:
        active: true
    UnnecessarySafeCall:
        active: true
    UnsafeCallOnNullableType:
        active: true
    UnsafeCast:
        active: true
    UnusedUnaryOperator:
        active: true
    UselessPostfixExpression:
        active: true
Enter fullscreen mode Exit fullscreen mode

.editorconfig

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
max_line_length = 120
tab_width = 4

[*.{kt,kts}]
ktlint_code_style = android_studio
ktlint_ignore_back_ticked_identifier = true

# Note that rules in any ruleset other than the standard ruleset will need to be prefixed
# by the ruleset identifier.
ktlint_standard_filename = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_no-empty-file = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_colon-spacing = disabled
ktlint_standard_no-blank-line-before-rbrace = disabled
ktlint_standard_function-expression-body = disabled
ij_kotlin_imports_layout=*,java.**,javax.**,kotlin.**,^ # default IntelliJ IDEA style, same as alphabetical, but with "java", "javax", "kotlin" and alias imports in the end of the imports list



# Enforce the Kotlin Modern code style
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL

# These rules conflict with ktlint, so they need to be disabled
ij_kotlin_allow_trailing_comma = false
ij_kotlin_allow_trailing_comma_on_call_site = false
Enter fullscreen mode Exit fullscreen mode

Scripts

scripts/get-staged-files.sh

#!/bin/bash

# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
LIGHT_GREY='\033[0;37m'
NC='\033[0m' # No Color

# Configuration: maximum number of lines for modified files
# Files with more lines will be excluded, but new files are always included
MAX_LINES_THRESHOLD=750
# Get list of staged Kotlin files, but separate modified and added files
MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M HEAD | grep ".*kt$")
ADDED_FILES=$(git diff --cached --name-only --diff-filter=A HEAD | grep ".*kt$")

# Filter out modified files that exceed the line threshold
FILTERED_FILES=""
for FILE in $MODIFIED_FILES; do
    LINE_COUNT=$(wc -l < "$FILE")
    if [ "$LINE_COUNT" -le "$MAX_LINES_THRESHOLD" ]; then
        FILTERED_FILES+="$FILE"$'\n'
    else
        echo -e "${LIGHT_GREY}Skipping $FILE (${LINE_COUNT} lines exceed threshold of $MAX_LINES_THRESHOLD)${NC}" >&2
    fi
done

# Add all new files to the list
for FILE in $ADDED_FILES; do
    FILTERED_FILES+="$FILE"$'\n'
done

# Output the filtered files (one per line), removing any trailing newline
echo -n "$FILTERED_FILES" | grep -v "^$"
Enter fullscreen mode Exit fullscreen mode

scripts/pre-commit.sh

#!/bin/bash

# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
LIGHT_BLUE='\033[1;34m'
NC='\033[0m' # No Color

# Get git root directory
GIT_ROOT=$(git rev-parse --show-toplevel)

# Helper function to run scripts and handle their exit codes
run_script() {
    local script_path=$1
    local script_name=$(basename "$script_path")

    # Check if script exists
    if [ ! -f "$script_path" ]; then
        echo -e "${YELLOW}Warning: ${script_name} not found, skipping...${NC}"
        echo
        return 0
    fi

    echo -e "${LIGHT_BLUE}Running ${script_name%.*}.sh${NC}"
    $script_path
    local result=$?
    echo

    if [ $result -ne 0 ]; then
        echo -e "${RED}${script_name%.*} failed! Abort commit...${NC}"
        exit 1
    fi
}

# Make scripts executable if they exist
[ -f "$GIT_ROOT/scripts/get-staged-files.sh" ] && chmod +x "$GIT_ROOT/scripts/get-staged-files.sh"
[ -d "$GIT_ROOT/scripts/tools" ] && find "$GIT_ROOT/scripts/tools" -type f -name "*.sh" -exec chmod +x {} \;

SCRIPTS=(
    "$GIT_ROOT/scripts/tools/run_ktlint.sh"
    "$GIT_ROOT/scripts/tools/run_detekt.sh"
    # Add more scripts here
)

# Run each script
for script in "${SCRIPTS[@]}"; do
    run_script "$script"
done

echo -e "${GREEN}Pre-commit process completed! (Some checks may have been skipped)${NC}"
exit 0
Enter fullscreen mode Exit fullscreen mode

scripts/tools/run_detekt.sh

#!/bin/bash

# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo -e "${YELLOW}Running code analysis...${NC}"

GIT_ROOT=$(git rev-parse --show-toplevel)

# Get staged files from the separate scripts
STAGED_FILES=$(./scripts/get-staged-files.sh)

# Exit if no files to analyze after filtering
if [ -z "$STAGED_FILES" ]; then
    echo -e "${YELLOW}No eligible Kotlin files found${NC}"
    exit 0
fi

# Count total files
TOTAL_FILES=$(echo "$STAGED_FILES" | wc -l)

# Display files to be analyzed
echo -e "${YELLOW}Found $TOTAL_FILES Kotlin files to analyze:${NC}"
echo "  - ${STAGED_FILES//$'\n'/$'\n  - '}"
echo

# Convert to comma-separated list for gradle using platform-agnostic approach
FILES_ARG=""
while IFS= read -r FILE; do
    if [ -n "$FILE" ]; then  # Skip empty lines
        if [ -z "$FILES_ARG" ]; then
            FILES_ARG="$FILE"
        else
            FILES_ARG="$FILES_ARG,$FILE"
        fi
    fi
done <<< "$STAGED_FILES"

# Run detekt on staged files
echo -e "${YELLOW}Running detekt on staged files...${NC}"
./gradlew -q detekt -PinputFiles="$FILES_ARG"
DETEKT_STATUS=$?
if [ $DETEKT_STATUS -eq 0 ]; then
    echo -e "${GREEN}Detekt check passed successfully!${NC}"
    exit 0
else
    echo -e "${RED}Detekt check failed with exit code $DETEKT_STATUS${NC}"

    # Show report location and try to open it
    REPORT_PATH="$GIT_ROOT/build/reports/detekt/detekt.html"
    echo -e "${YELLOW}Detekt report available at:${NC}"
    echo -e "$REPORT_PATH"

    # Try to open the report based on OS
    case "$(uname -s)" in
        Darwin*)    # macOS
            open "$REPORT_PATH" 2>/dev/null || true
            ;;
        Linux*)     # Linux
            xdg-open "$REPORT_PATH" 2>/dev/null || true
            ;;
        MINGW*|CYGWIN*) # Windows
            start "$REPORT_PATH" 2>/dev/null || true
            ;;
    esac

    exit 0
fi
Enter fullscreen mode Exit fullscreen mode

scripts/tools/run_ktlint.sh

#!/bin/bash

# Colors for output (using echo -e to interpret escape sequences)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo -e "${YELLOW}Running format check...${NC}"

# Get staged files from the separate scripts
STAGED_FILES=$(./scripts/get-staged-files.sh)

# Check if any Kotlin files are staged after filtering
if [ -z "$STAGED_FILES" ]; then
    echo -e "${YELLOW}No Kotlin files to format${NC}"
    exit 0
fi

# Count total files
TOTAL_FILES=$(echo "$STAGED_FILES" | wc -l)

# Display files to be formatted
echo -e "${YELLOW}Found $TOTAL_FILES Kotlin files to format:${NC}"
echo "  - ${STAGED_FILES//$'\n'/$'\n  - '}"
echo

# Convert to comma-separated list for gradle using platform-agnostic approach
FILES_ARG=""
while IFS= read -r FILE; do
    if [ -n "$FILE" ]; then  # Skip empty lines
        if [ -z "$FILES_ARG" ]; then
            FILES_ARG="$FILE"
        else
            FILES_ARG="$FILES_ARG,$FILE"
        fi
    fi
done <<< "$STAGED_FILES"

# Run ktlint format on staged files
echo -e "${YELLOW}Running ktlint format on staged files...${NC}"
./gradlew -q formatKotlinMain -PinputFiles="$FILES_ARG"

# Add back the formatted files to staging
echo -e "${YELLOW}Adding formatted files back to git staging...${NC}"
for FILE in $STAGED_FILES; do
    if [ -f "$FILE" ]; then
        git add "$FILE"
    fi
done

echo -e "${GREEN}Formatting complete!${NC}"
Enter fullscreen mode Exit fullscreen mode

add the following in you build.gradle.kts (:app module)

/**
 * Static code analysis configuration.
 */

allprojects {
    apply(plugin = "org.jmailen.kotlinter")
    kotlinter {
        ktlintVersion = "1.5.0"
        ignoreFormatFailures = true
        ignoreLintFailures = false
        reporters = arrayOf("html")
    }
    afterEvaluate {
        tasks.withType<LintTask>().configureEach {
            source = project.files(project.resolveInputFiles()).asFileTree
        }
        tasks.withType<FormatTask>().configureEach {
            source = project.files(project.resolveInputFiles()).asFileTree
        }
    }
}

dependencies {
    detektPlugins(libs.detekt.formatting)
}

detekt {
    buildUponDefaultConfig = false // preconfigure defaults
    allRules = false // activate all available (even unstable) rules
    config.setFrom(project.rootProject.file(".detekt/config.yml"))
    baseline = project.rootProject.file("$.detekt/baseline.xml")
}

tasks.withType<Detekt>().configureEach {
    reports {
        sarif.required.set(false)
        xml.required.set(false)
        md.required.set(false)
        txt.required.set(false)
        html.required.set(true)
    }
    source = project.files(project.resolveInputFiles()).asFileTree
}

tasks.register("enablePreCommitCheck") {
    group = "git hooks"
    description = "Installs the pre-commit hook for ktlint and detekt checks"

    doLast {
        // Get the source and destination files
        val sourceFile = project.rootProject.file("scripts/pre-commit.sh")
        val hooksDir = project.rootProject.file(".git/hooks")
        val targetFile = hooksDir.resolve("pre-commit")

        // Create hooks directory if it doesn't exist
        hooksDir.mkdirs()

        // Copy the file
        if (sourceFile.exists()) {
            sourceFile.copyTo(targetFile, overwrite = true)

            // Make it executable
            targetFile.setExecutable(true)

            println("Pre-commit hook installed successfully!")
            println("Location: ${targetFile.absolutePath}")
        } else {
            throw GradleException("Source file not found: ${sourceFile.absolutePath}")
        }
    }
}

tasks.register("disablePreCommitCheck") {
    group = "git hooks"
    description = "Remove the pre-commit hook for ktlint and detekt checks"

    doLast {
        val precommitFile = project.rootProject.file(".git/hooks/pre-commit")
        if (precommitFile.exists()) {
            precommitFile.delete()
            println("Pre-commit hook has been removed")
        } else {
            println("No Pre-commit hook detected")
        }
    }
}

fun Project.resolveInputFiles(): Set<File> {
    if (!hasProperty("inputFiles")) {
        return emptySet()
    }

    val filesList = property("inputFiles").toString()
        .split(",")
        .filter { it.isNotBlank() }
        .mapNotNull { path ->
            projectDir.resolve(path).takeIf { it.exists() }
                ?: rootDir.resolve(path).takeIf { it.exists() }
        }
        .toSet()

    if (filesList.isEmpty()) {
        throw GradleException("No valid files found from 'inputFiles' property.")
    }

    return filesList
}
Enter fullscreen mode Exit fullscreen mode

Usage

  • Sync gradle
  • Run :enablePreCommitCheck task ( ./gradlew :enablePreCommitCheck ) just once
  • Done, the SCA tools will be triggered each time we push committed changes

Expected Benefits

Implementing pre-commit hooks for static code analysis will deliver several important advantages to our development process:

  • Streamlined Code Reviews: Developers and reviewers can focus on logic, architecture, and functionality rather than formatting issues
  • Consistent Code Style: Automatic enforcement of formatting standards across all modified files ensures stylistic uniformity regardless of which developer made the changes
  • Reduced Technical Debt: Preventing quality issues before they enter the codebase eliminates the accumulation of small but impactful technical debt
  • Earlier Issue Detection: Identifying potential problems at commit time rather than during code review or testing accelerates the feedback loop
  • Improved Developer Experience: Developers receive immediate feedback on their code quality without waiting for review comments
  • Enhanced Maintainability: Consistent formatting and adherence to quality standards make the codebase more navigable and understandable
  • Smoother Onboarding: New team members can quickly adapt to project standards with automated guidance

These benefits directly address typical challenges in a large legacy android project that has multiple distributed development squad.

Top comments (1)

Collapse
 
jance_jacobs profile image
Jance Jacobs

Thanks for diving into such a niche, often overlooked topic—legacy Android + SCA setup details like this are super valuable.