📲

Android端末上でAndroidアプリをビルドする

に公開
4

Discussion

mojabimojabi

Androidのアプリ開発はあまり詳しくないですが、非常に参考になりました。
aapt2の差し替えは、zip/unzipではうまくいかず、jar cf/jar xfで圧縮/解凍を行うと、期待通りの動作をしました。
私の環境(Pixel9 Fold)では、gradle.properties-Xmx8g -Xms8gと設定しないと、aapt2が動作しませんでした。
急いで作ったのでかなりベタベタですが、以下のような適当なスクリプトで最小限のプロジェクトを作って、いろいろいじって遊んでいます。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import importlib.util
import textwrap
import sys

# ----- Utilities -----
def check_packages(packages):
    not_installed = []
    for package in packages:
        if importlib.util.find_spec(package) is None:
            not_installed.append(package)
    return not_installed

def bail(message, status):
    print(message)
    sys.exit(status)

def help():
    help = '''
    android_create_project.py <target_sdk> <min_sdk> <package_name> <project_name> <output_dir>
    '''
    bail(textwrap.dedent(help).strip(), 1)

# ----- main -----
def main(target_sdk, min_sdk, package_name, project_name, output_dir):
    # プロジェクトのディレクトリを作成
    project_path = os.path.join(output_dir, project_name)
    os.makedirs(project_path, exist_ok=True)

    # 各パスの作成
    app_path = os.path.join(project_path, 'app')
    main_path = os.path.join(app_path, 'src/main')
    java_path = os.path.join(main_path, 'java')
    package_path = os.path.join(java_path, package_name.replace('.', '/'))
    res_path = os.path.join(main_path, 'res')
    mipmap_path = os.path.join(res_path, 'mipmap-xxxhdpi')
    layout_path = os.path.join(res_path, 'layout')
    values_path = os.path.join(res_path, 'values')
    
    # ディレクトリの作成
    os.makedirs(os.path.join(package_path), exist_ok=True)
    os.makedirs(os.path.join(mipmap_path), exist_ok=True)
    os.makedirs(os.path.join(layout_path), exist_ok=True)
    os.makedirs(os.path.join(values_path), exist_ok=True)

    # 各ファイルの出力
    ### gradle.properties
    gradle_properties = f"""org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Xmx8g -Xms8g
android.useAndroidX=true
    """

    ### build.gradle (ルート)
    root_build_gradle = f"""buildscript {{
    repositories {{
        google()
        mavenCentral()
    }}
    dependencies {{
        classpath "com.android.tools.build:gradle:8.5.1"
    }}
}}

allprojects {{
    repositories {{
        google()
        mavenCentral()
    }}
}}
    """

    # app/build.gradle
    app_build_gradle = f"""plugins {{
    id 'com.android.application'
}}

android {{
    namespace '{package_name}'

    compileSdk {target_sdk}

    defaultConfig {{
        applicationId "{package_name}"
        minSdk {min_sdk}
        targetSdk {target_sdk}
        versionCode 1
        versionName "1.0"
    }}

    buildTypes {{
        release {{
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }}
    }}
}}

dependencies {{
    implementation 'androidx.appcompat:appcompat:1.3.1'
    implementation 'com.google.android.material:material:1.4.0'
}}
    """

    # AndroidManifest.xml
    android_manifest = f"""<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="{package_name}">
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.{project_name}">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
    """

    ### MainActivity.java
    main_activity = f"""package {package_name};

import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {{
    @Override
    protected void onCreate(Bundle savedInstanceState) {{
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }}
}}
    """

    ### activity_main.xml
    activity_main_xml = f"""<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
</FrameLayout>
    """

    ### strings.xml
    strings_xml = f"""<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">{project_name}</string>
</resources>
    """

    ### themes.xml
    themes_xml = f"""<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
  <style name="Theme.{project_name}" parent="Theme.Design.Light.NoActionBar">
  </style>
</resources>
    """

    # ファイル出力
    with open(os.path.join(project_path, 'gradle.properties'), 'w') as f:
        f.write(gradle_properties)

    with open(os.path.join(project_path, 'build.gradle'), 'w') as f:
        f.write(root_build_gradle)

    with open(os.path.join(app_path, 'build.gradle'), 'w') as f:
        f.write(app_build_gradle)

    with open(os.path.join(main_path, 'AndroidManifest.xml'), 'w') as f:
        f.write(android_manifest)
    
    with open(os.path.join(package_path, 'MainActivity.java'), 'w') as f:
        f.write(main_activity)

    with open(os.path.join(layout_path, 'activity_main.xml'), 'w') as f:
        f.write(activity_main_xml)

    with open(os.path.join(values_path, 'strings.xml'), 'w') as f:
        f.write(strings_xml)
    
    with open(os.path.join(values_path, 'themes.xml'), 'w') as f:
        f.write(themes_xml)

    # アイコン作成
    width, height = 192, 192
    color = (255, 0, 0)  # RGB値で赤色
    image = Image.new('RGB', (width, height), color)
    image.save(os.path.join(mipmap_path, 'ic_launcher.png'))
    image.save(os.path.join(mipmap_path, 'ic_launcher_round.png')) # TODO
        
    # Gradleラッパーを生成
    subprocess.run(['gradle', 'wrapper', '--gradle-version', '8.12'], cwd=project_path, check=True)

    ### settings.gradle
    settings_gradle = f"""rootProject.name = '{project_name}'
include ':app'
    """

    with open(os.path.join(project_path, 'settings.gradle'), 'w') as f:
        f.write(settings_gradle)


# ----- Entry point -----
if __name__ == "__main__":
    required_packages = ['PIL']
    not_installed = check_packages(required_packages)
    
    if not_installed:
        print("本スクリプトの実行には、以下のパッケージをインストールする必要があります。\n")
        for package in not_installed:
            print(f" - {package}\n")
        sys.exit(1)
    else:
        import os
        import subprocess
        import sys
        from PIL import Image

        args = sys.argv

        if len(args) >= 2 and (args[1] == '-h' or args[1] == '--help'):
            help()

        if len(args) == 6:
            target_sdk = args[1]
            min_sdk = args[2]
            package_name = args[3]
            project_name = args[4]
            output_dir = args[5]
            main(target_sdk, min_sdk, package_name, project_name, output_dir)
            sys.exit(0)
        
        help()
turtlekazu🐢turtlekazu🐢

コメントありがとうございます!
zip/unzipではうまくいかない場合があるとは思い至りませんでした。ご共有くださいまして、ありがとうございます。
-Xmx8g -Xms8gはかなり大きな値ですね。。。Pixel Foldだと、Android端末上の開発も快適にできそうです。
Androidプロジェクト自体をPythonから生成してしまうの、すごすぎます👀

mojabimojabi

返信ありがとうございます。
本当に貴重な記事をありがとうございます。

コンパイルからインストールまで、なんとかコマンドライン一発でできないかと思い、以下のようなアプリを作成しました。

import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;

import java.io.File;

public class MainActivity
    extends AppCompatActivity {
    private static final String TAG = MainActivity.class.getSimpleName();
    private String apkPath;
    private LinearLayout layout;

    @Override
    protected void onNewIntent(Intent intent) {
	super.onNewIntent(intent);
	if (intent == null) {
	    return;
	}
        apkPath = intent.getStringExtra("apkPath");
        if (apkPath != null) {
	    installApk(apkPath);
        } else {
            Toast
		.makeText(this,
			  getString(R.string.message_no_arguement_apk_path),
			  Toast.LENGTH_SHORT)
		.show();
        }
	finish();
    }
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
	layout = new LinearLayout(this);
	layout.setOrientation(LinearLayout.VERTICAL);

	setContentView(layout);

	if (checkStoragePermission()) {
	    onNewIntent(getIntent());
	    return;
	}
	
	Button button = new Button(this);
	button.setText(getString(R.string.title_request_permission));
	button.setOnClickListener(v -> {
		requestAllFilesAccess();
	    });
	layout.addView(button);
    }

    public boolean checkStoragePermission() {
	return (ActivityCompat.checkSelfPermission
		(this, Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
		== PackageManager.PERMISSION_GRANTED)
	    || (ActivityCompat.checkSelfPermission
		(this, Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
		== PackageManager.PERMISSION_GRANTED)
	    || Environment.isExternalStorageManager();
    }
    
    public void requestAllFilesAccess() {
	if (Environment.isExternalStorageManager()) {
	    return;
	}
	requestAllFilesAccessPermission();
    }

    private void requestAllFilesAccessPermission() {
	try {
	    Intent intent =
		new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
		.setData(Uri.parse("package:" + getPackageName()));
	    startActivity(intent);
	} catch (ActivityNotFoundException anf) {
	    try {
		Intent intent =
		    new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
		    .setData(Uri.parse("package:$packageName"));
		startActivity(intent);
	    } catch (Exception e) {
		Toast
		    .makeText(this,
			      getString(R.string.message_failed_to_get_permission),
			      Toast.LENGTH_SHORT)
		    .show();
	    }
	} catch (Exception e) {
	    Toast.makeText(this,
			   getString(R.string.message_failed_to_initialize_activity_to_request_all_files_access),
			   Toast.LENGTH_SHORT)
		.show();
	}
    }
    
    private void installApk(String apkPath) {
	try {
	    File file = new File(apkPath);
	    if (file.exists()) {
		Uri uri = FileProvider.getUriForFile
		    (this, getApplicationContext().getPackageName() + ".provider", file);
		Intent intent = new Intent(Intent.ACTION_VIEW);
		intent.setDataAndType(uri, "application/vnd.android.package-archive");
		intent.setFlags
		    (Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
		startActivity(intent);
		Toast
		    .makeText(this,
			      getString(R.string.message_start_to_install_apk_file),
			      Toast.LENGTH_SHORT)
		    .show();
	    } else {
		Toast
		    .makeText(this,
			      getString(R.string.message_no_apk_file),
			      Toast.LENGTH_SHORT)
		    .show();
	    }
	} catch (Exception e) {
	    Toast
		.makeText(this,
			  getString(R.string.message_failed_to_install_apk_file),
			  Toast.LENGTH_SHORT)
		.show();
	}
    }
}

文字列リソースは以下のような感じです。

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">インストーラー</string>
  <string name="title_request_permission">権限をリクエスト</string>
  <string name="message_no_arguement_apk_path">APKファイルパスを指定してください。</string>
  <string name="message_failed_to_get_permission">必要な権限の取得に失敗しました。</string>
  <string name="message_failed_to_initialize_activity_to_request_all_files_access">権限取得用画面の初期化に失敗しました。</string>
  <string name="message_start_to_install_apk_file">インストールを開始しました。</string>
  <string name="message_no_apk_file">正しいAPKファイルパスを指定してください。</string>
  <string name="message_failed_to_install_apk_file">インストール中にエラーが発生しました。</string>
</resources>

上記を、例えばパッケージ名 com.example.app.installer に設定してコンパイルし、ファイルマネージャーなどからインストールします。

起動後に画面上のボタンを押して「すべてのファイルへのアクセス」権限をオンにした状態で、Ubuntu on Termux から以下のコマンドを実行すると、Androidのインストーラー起動まではコマンド一発でいけます。
予め、 /storage/emulated/0/Termux ディレクトリを作成しておいてください。
例えば MyApp というプロジェクト内で実行すると、こんなかんじです。

app=MyApp && ./gradlew assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk ~/Android/${app}.apk && am start -n com.examle.app.installer/.MainActivity --es apkPath /storage/emulated/0/Termux/${app}.apk

セキュリテイの制約で、何回か画面をタップしないとコンパイルしたアプリをインストールできませんが、無いよりかましかと。

これで気になるコードを、Android端末だけでたくさん試すことができます。

私はEmacsユーザーなので、上記コマンドをEmacs上から生成して実行できるLispを作成して C-r キーで実行しています。
記事中のVSCodeは使ったことがないのですが、たしかVSCodeでもターミナル機能があったと思うので、同じように実行できるかと。

mojabimojabi

すみません。マニフェストと

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
	  xmlns:tools="http://schemas.android.com/tools"
	  package="com.example.app.installer">
  
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Design.Light.NoActionBar">
      
      <activity
          android:name=".MainActivity"
	  android:launchMode="singleTop"
	  android:exported="true">
        <intent-filter>
          <action android:name="android.intent.action.MAIN" />
          <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
      </activity>

      <provider
	  android:name="androidx.core.content.FileProvider"
	  android:authorities="${applicationId}.provider"
	  android:exported="false"
	  android:grantUriPermissions="true">
	<meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/provider_paths"/>
      </provider>
    </application>
    
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
</manifest>

providerです。

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path path="Android/data/${applicationId}/" name="package_root" />
    <cache-path path="Android/data/${applicationId}/" name="cache" />
    <root-path path="." name="storage_root" />
</paths>

これがないと権限関連が混乱する話になるところでした。。。