Spaces:
Sleeping
Sleeping
from flask import Flask, request, send_file, render_template_string | |
import os | |
import shutil | |
import subprocess | |
import tempfile | |
import logging | |
from markupsafe import escape | |
import glob | |
app = Flask(__name__) | |
# Configure logging | |
logging.basicConfig(level=logging.INFO) | |
logger = logging.getLogger(__name__) | |
# HTML template with license checkbox | |
HTML_TEMPLATE = """ | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Python to APK Converter</title> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> | |
<style> | |
body { | |
background-color: #f8f9fa; | |
} | |
.container { | |
max-width: 800px; | |
margin-top: 2rem; | |
} | |
.card { | |
border-radius: 10px; | |
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); | |
} | |
.form-label { | |
font-weight: 500; | |
} | |
.btn-primary { | |
background-color: #007bff; | |
border: none; | |
padding: 0.75rem 1.5rem; | |
} | |
.btn-primary:hover { | |
background-color: #0056b3; | |
} | |
.alert { | |
margin-top: 1rem; | |
} | |
#progress-container { | |
display: none; | |
margin-top: 1rem; | |
} | |
.download-link { | |
font-size: 1.1rem; | |
margin-top: 1rem; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="card p-4"> | |
<h1 class="text-center mb-4">Python to Android APK Converter</h1> | |
<form id="convert-form" method="POST" action="/convert"> | |
<div class="mb-3"> | |
<label for="app_name" class="form-label">App Name (alphanumeric, no spaces)</label> | |
<input type="text" class="form-control" id="app_name" name="app_name" required pattern="[A-Za-z0-9]+"> | |
<div class="invalid-feedback">Please enter a valid alphanumeric app name with no spaces.</div> | |
</div> | |
<div class="mb-3"> | |
<label for="code" class="form-label">Python Code (Toga-compatible, e.g., app.py)</label> | |
<textarea class="form-control" id="code" name="code" rows="10" required></textarea> | |
</div> | |
<div class="mb-3"> | |
<label for="requirements" class="form-label">Requirements.txt (optional, list dependencies like requests==2.28.1)</label> | |
<textarea class="form-control" id="requirements" name="requirements" rows="5"></textarea> | |
</div> | |
<div class="mb-3 form-check"> | |
<input type="checkbox" class="form-check-input" id="license_agreement" name="license_agreement" required> | |
<label class="form-check-label" for="license_agreement"> | |
I accept the <a href="https://developer.android.com/studio/terms" target="_blank">Android SDK License Agreement</a> | |
</label> | |
<div class="invalid-feedback">You must accept the Android SDK License Agreement to proceed.</div> | |
</div> | |
<button type="submit" class="btn btn-primary w-100" id="convert-btn" disabled>Convert to APK</button> | |
</form> | |
<div id="progress-container" class="text-center"> | |
<div class="spinner-border text-primary" role="status"> | |
<span class="visually-hidden">Converting...</span> | |
</div> | |
<p class="mt-2">Converting your code to APK...</p> | |
</div> | |
{% if error %} | |
<div class="alert alert-danger mt-3" role="alert"> | |
<strong>Error:</strong> {{ error | safe }} | |
</div> | |
{% endif %} | |
{% if apk_path %} | |
<div class="alert alert-success mt-3" role="alert"> | |
<strong>Success!</strong> APK generated! | |
<a href="/download/{{ apk_path }}" class="download-link">Download APK</a> | |
</div> | |
{% endif %} | |
{% if logs %} | |
<div class="mt-3"> | |
<h5>Build Logs</h5> | |
<pre class="border p-3 bg-light" style="max-height: 300px; overflow-y: auto;">{{ logs | safe }}</pre> | |
</div> | |
{% endif %} | |
</div> | |
</div> | |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |
<script> | |
// Enable submit button only when license is accepted | |
const licenseCheckbox = document.getElementById('license_agreement'); | |
const convertBtn = document.getElementById('convert-btn'); | |
licenseCheckbox.addEventListener('change', function() { | |
convertBtn.disabled = !this.checked; | |
}); | |
// Form submission with progress indicator | |
document.getElementById('convert-form').addEventListener('submit', function() { | |
convertBtn.disabled = true; | |
document.getElementById('progress-container').style.display = 'block'; | |
}); | |
// Client-side validation for app name | |
const appNameInput = document.getElementById('app_name'); | |
appNameInput.addEventListener('input', function() { | |
if (appNameInput.validity.patternMismatch) { | |
appNameInput.classList.add('is-invalid'); | |
} else { | |
appNameInput.classList.remove('is-invalid'); | |
} | |
}); | |
</script> | |
</body> | |
</html> | |
""" | |
def index(): | |
return render_template_string(HTML_TEMPLATE) | |
def convert(): | |
app_name = request.form.get("app_name").replace(" ", "_") | |
code = request.form.get("code") | |
requirements = request.form.get("requirements") | |
license_agreement = request.form.get("license_agreement") | |
logs = [] | |
# Validate license agreement | |
if license_agreement != "on": | |
logs.append("Validation error: You must accept the Android SDK License Agreement.") | |
return render_template_string(HTML_TEMPLATE, error="You must accept the Android SDK License Agreement to proceed.", logs="\n".join(logs)) | |
# Validate app name | |
if not app_name.isalnum(): | |
logs.append("Validation error: App name must be alphanumeric with no spaces.") | |
return render_template_string(HTML_TEMPLATE, error="App name must be alphanumeric with no spaces.", logs="\n".join(logs)) | |
# Log Python and pip versions for debugging | |
try: | |
python_version = subprocess.run( | |
["python3", "--version"], | |
capture_output=True, | |
text=True, | |
check=True | |
).stdout.strip() | |
logs.append(f"Python version: {python_version}") | |
pip_version = subprocess.run( | |
["python3", "-m", "pip", "--version"], | |
capture_output=True, | |
text=True, | |
check=True | |
).stdout.strip() | |
logs.append(f"pip version: {pip_version}") | |
except subprocess.CalledProcessError as e: | |
logs.append("Error: Failed to verify Python or pip:") | |
logs.append(e.stderr) | |
return render_template_string(HTML_TEMPLATE, error="Failed to verify Python/pip environment.", logs="\n".join(logs)) | |
# Create a temporary directory for the project | |
with tempfile.TemporaryDirectory() as temp_dir: | |
project_dir = os.path.join(temp_dir, app_name) | |
logs.append(f"Created temporary project directory: {project_dir}") | |
os.makedirs(project_dir, exist_ok=True) | |
# Create the Python app structure | |
src_dir = os.path.join(project_dir, "src", app_name) | |
os.makedirs(src_dir, exist_ok=True) | |
logs.append(f"Created source directory: {src_dir}") | |
# Write the user-provided code to app.py | |
app_file = os.path.join(src_dir, "app.py") | |
with open(app_file, "w") as f: | |
f.write(code) | |
logs.append(f"Wrote user code to: {app_file}") | |
# Write requirements.txt if provided | |
if requirements and requirements.strip(): | |
req_file = os.path.join(project_dir, "requirements.txt") | |
with open(req_file, "w") as f: | |
f.write(requirements) | |
logs.append(f"Wrote requirements to: {req_file}") | |
try: | |
# Install dependencies as the current user | |
result = subprocess.run( | |
["python3", "-m", "pip", "install", "--user", "--no-cache-dir", "-r", req_file], | |
check=True, | |
capture_output=True, | |
text=True, | |
env={"HOME": "/home/appuser", "PATH": os.environ.get("PATH")} | |
) | |
logs.append("Installed dependencies successfully:") | |
logs.append(result.stdout) | |
if result.stderr: | |
logs.append("Dependency installation warnings:") | |
logs.append(result.stderr) | |
except subprocess.CalledProcessError as e: | |
logs.append("Failed to install dependencies:") | |
logs.append(e.stderr) | |
return render_template_string(HTML_TEMPLATE, error=f"Failed to install dependencies: {escape(e.stderr)}", logs="\n".join(logs)) | |
# Create a minimal pyproject.toml for BeeWare | |
pyproject_content = f""" | |
[tool.briefcase] | |
project_name = "{app_name}" | |
bundle = "com.example" | |
version = "0.0.1" | |
description = "A Python app converted to APK" | |
license = "MIT" | |
[tool.briefcase.app.{app_name}] | |
formal_name = "{app_name}" | |
icon = "" | |
sources = ["src/{app_name}"] | |
[tool.briefcase.app.{app_name}.android] | |
build_tool = "gradle" | |
""" | |
if requirements and requirements.strip(): | |
pyproject_content += f""" | |
requirements = [{', '.join(f'"{line.strip()}"' for line in requirements.splitlines() if line.strip())}] | |
""" | |
pyproject_file = os.path.join(project_dir, "pyproject.toml") | |
with open(pyproject_file, "w") as f: | |
f.write(pyproject_content) | |
logs.append(f"Wrote pyproject.toml to: {pyproject_file}") | |
try: | |
# Change to project directory | |
os.chdir(project_dir) | |
logs.append(f"Changed working directory to: {project_dir}") | |
# Verify Briefcase installation | |
briefcase_version = subprocess.run( | |
["briefcase", "--version"], | |
capture_output=True, | |
text=True, | |
check=True | |
).stdout.strip() | |
logs.append(f"Briefcase version: {briefcase_version}") | |
# Log environment variables for debugging | |
logs.append(f"Environment PATH: {os.environ.get('PATH')}") | |
logs.append(f"Android SDK Home: {os.environ.get('ANDROID_HOME')}") | |
logs.append(f"Android SDK Root: {os.environ.get('ANDROID_SDK_ROOT')}") | |
logs.append(f"Java Home: {os.environ.get('JAVA_HOME', 'Not set')}") | |
# Verify Android SDK tools | |
try: | |
sdkmanager_version = subprocess.run( | |
["sdkmanager", "--version"], | |
capture_output=True, | |
text=True, | |
check=True | |
).stdout.strip() | |
logs.append(f"sdkmanager version: {sdkmanager_version}") | |
except subprocess.CalledProcessError as e: | |
logs.append("Failed to verify sdkmanager version:") | |
logs.append(e.stderr) | |
return render_template_string(HTML_TEMPLATE, error="Failed to verify Android SDK tools.", logs="\n".join(logs)) | |
# Verify Java installation | |
try: | |
java_version = subprocess.run( | |
["java", "-version"], | |
capture_output=True, | |
text=True, | |
check=True | |
).stderr.strip() # java -version outputs to stderr | |
logs.append(f"Java version: {java_version}") | |
except subprocess.CalledProcessError as e: | |
logs.append("Failed to verify Java version:") | |
logs.append(e.stderr) | |
return render_template_string(HTML_TEMPLATE, error="Failed to verify Java installation.", logs="\n".join(logs)) | |
# Ensure Android SDK licenses are accepted | |
try: | |
logs.append("Ensuring Android SDK licenses are accepted...") | |
result_licenses = subprocess.run( | |
["sdkmanager", "--licenses"], | |
input="y\n" * 10, # Accept all licenses | |
capture_output=True, | |
text=True, | |
check=True, | |
env={ | |
"HOME": "/home/appuser", | |
"PATH": os.environ.get("PATH"), | |
"ANDROID_HOME": "/opt/android-sdk", | |
"ANDROID_SDK_ROOT": "/opt/android-sdk", | |
"JAVA_HOME": "/usr/lib/jvm/java-17-openjdk-amd64" | |
} | |
) | |
logs.append("License acceptance output:") | |
logs.append(result_licenses.stdout) | |
if result_licenses.stderr: | |
logs.append("License acceptance warnings/errors:") | |
logs.append(result_licenses.stderr) | |
except subprocess.CalledProcessError as e: | |
logs.append("Failed to accept Android SDK licenses:") | |
logs.append(f"Return code: {e.returncode}") | |
logs.append(f"Stdout: {e.stdout}") | |
logs.append(f"Stderr: {e.stderr}") | |
return render_template_string(HTML_TEMPLATE, error="Failed to accept Android SDK licenses.", logs="\n".join(logs)) | |
# Run Briefcase create with verbose output and debug logging | |
logs.append("Running Briefcase create command...") | |
env = { | |
"HOME": "/home/appuser", | |
"PATH": os.environ.get("PATH"), | |
"ANDROID_HOME": "/opt/android-sdk", | |
"ANDROID_SDK_ROOT": "/opt/android-sdk", | |
"JAVA_HOME": "/usr/lib/jvm/java-17-openjdk-amd64" | |
} | |
try: | |
result_create = subprocess.run( | |
["briefcase", "create", "android", "-v", "--no-input"], | |
check=True, | |
capture_output=True, | |
text=True, | |
env=env | |
) | |
logs.append("Briefcase create output:") | |
logs.append(result_create.stdout) | |
if result_create.stderr: | |
logs.append("Briefcase create warnings/errors:") | |
logs.append(result_create.stderr) | |
except subprocess.CalledProcessError as e: | |
logs.append("Briefcase create failed with error:") | |
logs.append(f"Return code: {e.returncode}") | |
logs.append(f"Stdout: {e.stdout}") | |
logs.append(f"Stderr: {e.stderr}") | |
return render_template_string(HTML_TEMPLATE, error=f"Briefcase create failed: {escape(e.stderr or e.stdout)}", logs="\n".join(logs)) | |
except Exception as e: | |
logs.append(f"Unexpected error during Briefcase create: {str(e)}") | |
return render_template_string(HTML_TEMPLATE, error=f"Unexpected error: {str(e)}", logs="\n".join(logs)) | |
# Run Briefcase build | |
logs.append("Running Briefcase build command...") | |
try: | |
result_build = subprocess.run( | |
["briefcase", "build", "android", "-v", "--no-input"], | |
check=True, | |
capture_output=True, | |
text=True, | |
env=env | |
) | |
logs.append("Briefcase build output:") | |
logs.append(result_build.stdout) | |
if result_build.stderr: | |
logs.append("Briefcase build warnings/errors:") | |
logs.append(result_build.stderr) | |
except subprocess.CalledProcessError as e: | |
logs.append("Briefcase build failed with error:") | |
logs.append(f"Return code: {e.returncode}") | |
logs.append(f"Stdout: {e.stdout}") | |
logs.append(f"Stderr: {e.stderr}") | |
return render_template_string(HTML_TEMPLATE, error=f"Briefcase build failed: {escape(e.stderr or e.stdout)}", logs="\n".join(logs)) | |
# Locate the generated APK | |
apk_file = "app-debug.apk" | |
# Try the default path | |
apk_dir = os.path.join(project_dir, "build", app_name, "android", "gradle", "app", "build", "outputs", "apk", "debug") | |
apk_path = os.path.join(apk_dir, apk_file) | |
logs.append(f"Checking for APK at: {apk_path}") | |
# If not found, search for app-debug.apk in the project directory | |
if not os.path.exists(apk_path): | |
logs.append(f"APK not found at: {apk_path}") | |
apk_files = glob.glob(os.path.join(project_dir, "**", "app-debug.apk"), recursive=True) | |
if apk_files: | |
apk_path = apk_files[0] | |
logs.append(f"Found APK at alternative path: {apk_path}") | |
else: | |
logs.append("Error: APK not found in project directory.") | |
return render_template_string(HTML_TEMPLATE, error="APK not found after build.", logs="\n".join(logs)) | |
# Move APK to persistent directory | |
persistent_dir = "/persistent_storage" | |
os.makedirs(persistent_dir, exist_ok=True) | |
final_apk_path = os.path.join(persistent_dir, f"{app_name}.apk") | |
shutil.copy(apk_path, final_apk_path) | |
logs.append(f"Copied APK to: {final_apk_path}") | |
return render_template_string(HTML_TEMPLATE, apk_path=f"{app_name}.apk", logs="\n".join(logs)) | |
except subprocess.CalledProcessError as e: | |
logs.append("Build failed with the following error:") | |
logs.append(e.stderr) | |
return render_template_string(HTML_TEMPLATE, error=f"Build failed: {escape(e.stderr)}", logs="\n".join(logs)) | |
def download(filename): | |
try: | |
file_path = os.path.join("/persistent_storage", filename) | |
return send_file(file_path, as_attachment=True) | |
except FileNotFoundError: | |
return render_template_string(HTML_TEMPLATE, error="APK file not found for download.") | |
if __name__ == "__main__": | |
app.run(host="0.0.0.0", port=7860) |