Enhance Android build process and add settings for update management

- Implement caching for Android debug keystore
- Modify APK build command to include CI versioning
- Add logic to prepare release assets and upload to GitHub
- Update .gitignore to exclude build directories
- Introduce SettingsActivity for managing auto-update preferences
- Add UpdateManager for handling update checks and dialogs
- Integrate update check on app startup in MainActivity
This commit is contained in:
jahruz67
2026-05-21 16:21:21 -07:00
parent c1ad9c7a56
commit 9af47ed14a
8 changed files with 590 additions and 5 deletions
+35 -2
View File
@@ -26,18 +26,51 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
- name: Cache Android debug keystore
uses: actions/cache@v4
with:
path: ~/.android/debug.keystore
key: android-debug-keystore-${{ runner.os }}
- name: Make Gradle wrapper executable - name: Make Gradle wrapper executable
run: chmod +x gradlew run: chmod +x gradlew
- name: Build debug APK - name: Build debug APK
run: ./gradlew assembleDebug run: ./gradlew assembleDebug -PciVersionCode=${{ github.run_number }} -PciVersionName=1.0.${{ github.run_number }}
- name: Prepare release assets
shell: bash
run: |
VERSION_CODE="${{ github.run_number }}"
VERSION_NAME="1.0.${{ github.run_number }}"
APK_URL="https://github.com/jahruz67/Bible-Daily/releases/download/latest/app-debug.apk"
APK_PATH="$(find app/build/outputs/apk/debug -maxdepth 1 -name '*.apk' -print -quit)"
if [ -z "$APK_PATH" ]; then
echo "No APK found in app/build/outputs/apk/debug"
exit 1
fi
cp "$APK_PATH" app-debug.apk
cat > latest.json <<EOF
{
"versionCode": $VERSION_CODE,
"versionName": "$VERSION_NAME",
"apkUrl": "$APK_URL",
"commit": "${GITHUB_SHA}",
"builtAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
- name: Upload APK to latest release - name: Upload APK to latest release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: latest tag_name: latest
name: Latest APK name: Latest APK
files: app/build/outputs/apk/debug/app-debug.apk files: |
app-debug.apk
latest.json
make_latest: true make_latest: true
overwrite_files: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -1
View File
@@ -1,2 +1,4 @@
/.gradle /.gradle
/.idea /.idea
/build
/app/build
+5 -2
View File
@@ -2,6 +2,9 @@ plugins {
id "com.android.application" id "com.android.application"
} }
def ciVersionCode = providers.gradleProperty("ciVersionCode").getOrNull()
def ciVersionName = providers.gradleProperty("ciVersionName").getOrNull()
android { android {
namespace "com.bibliadiaria.app" namespace "com.bibliadiaria.app"
compileSdk 35 compileSdk 35
@@ -10,8 +13,8 @@ android {
applicationId "com.bibliadiaria.app" applicationId "com.bibliadiaria.app"
minSdk 23 minSdk 23
targetSdk 35 targetSdk 35
versionCode 1 versionCode ciVersionCode != null ? ciVersionCode.toInteger() : 1
versionName "1.0" versionName ciVersionName != null ? ciVersionName : "1.0"
} }
compileOptions { compileOptions {
+4
View File
@@ -9,6 +9,10 @@
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<activity
android:name=".SettingsActivity"
android:exported="false" />
<activity <activity
android:name=".ExtrasActivity" android:name=".ExtrasActivity"
android:exported="false" /> android:exported="false" />
@@ -1,6 +1,7 @@
package com.bibliadiaria.app; package com.bibliadiaria.app;
import android.app.Activity; import android.app.Activity;
import android.content.Intent;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.GradientDrawable;
@@ -153,6 +154,18 @@ public class ExtrasActivity extends Activity {
1f 1f
)); ));
TextView settings = textView("Ajustes", 15, COLOR_ACCENT, Typeface.BOLD);
settings.setGravity(Gravity.CENTER);
settings.setPadding(dp(14), 0, dp(14), 0);
settings.setBackground(roundedRect(Color.WHITE, dp(8), Color.rgb(219, 226, 222), dp(1)));
settings.setOnClickListener(view -> startActivity(new Intent(this, SettingsActivity.class)));
LinearLayout.LayoutParams settingsParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(40)
);
settingsParams.setMargins(0, 0, dp(8), 0);
topBar.addView(settings, settingsParams);
TextView home = textView("Inicio", 15, COLOR_ACCENT, Typeface.BOLD); TextView home = textView("Inicio", 15, COLOR_ACCENT, Typeface.BOLD);
home.setGravity(Gravity.CENTER); home.setGravity(Gravity.CENTER);
home.setPadding(dp(14), 0, dp(14), 0); home.setPadding(dp(14), 0, dp(14), 0);
@@ -91,6 +91,7 @@ public class MainActivity extends Activity {
setContentView(createScreen()); setContentView(createScreen());
loadToday(); loadToday();
checkForUpdateOnStartup();
} }
@Override @Override
@@ -600,6 +601,23 @@ public class MainActivity extends Activity {
loadSelectedDate(); loadSelectedDate();
} }
private void checkForUpdateOnStartup() {
if (!UpdateManager.isAutoUpdatesEnabled(this) || executor == null || executor.isShutdown()) {
return;
}
executor.submit(() -> {
try {
UpdateManager.UpdateInfo info = UpdateManager.fetchLatestUpdate(this);
if (info.updateAvailable) {
runOnUiThread(() -> UpdateManager.showUpdateDialog(this, info));
}
} catch (Exception ignored) {
// Startup update checks stay quiet so daily readings remain the main experience.
}
});
}
private void loadSelectedDate() { private void loadSelectedDate() {
loadDate(selectedDateCalendar.getTime()); loadDate(selectedDateCalendar.getTime());
} }
@@ -0,0 +1,331 @@
package com.bibliadiaria.app;
import android.app.Activity;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.Bundle;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SettingsActivity extends Activity {
private static final int COLOR_BACKGROUND = Color.rgb(247, 248, 246);
private static final int COLOR_CARD = Color.WHITE;
private static final int COLOR_INK = Color.rgb(25, 31, 31);
private static final int COLOR_MUTED = Color.rgb(91, 99, 98);
private static final int COLOR_ACCENT = Color.rgb(0, 107, 90);
private static final int COLOR_WARM = Color.rgb(217, 75, 61);
private ExecutorService executor;
private TextView statusText;
private TextView installedVersionText;
private TextView checkButton;
private TextView downloadButton;
private UpdateManager.UpdateInfo latestInfo;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(COLOR_BACKGROUND);
getWindow().setNavigationBarColor(COLOR_BACKGROUND);
}
executor = Executors.newSingleThreadExecutor();
setContentView(createScreen());
updateInstalledVersionText();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (executor != null) {
executor.shutdownNow();
}
}
private View createScreen() {
FrameLayout root = new FrameLayout(this);
root.setBackgroundColor(COLOR_BACKGROUND);
ScrollView scrollView = new ScrollView(this);
scrollView.setFillViewport(true);
scrollView.setClipToPadding(false);
LinearLayout content = new LinearLayout(this);
content.setOrientation(LinearLayout.VERTICAL);
content.setPadding(dp(20), dp(28), dp(20), dp(28));
scrollView.addView(content, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
content.addView(createTopBar());
TextView title = textView("Settings", 34, COLOR_INK, Typeface.BOLD);
title.setIncludeFontPadding(false);
LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
titleParams.setMargins(0, dp(18), 0, dp(18));
content.addView(title, titleParams);
content.addView(createAutoUpdatesCard());
content.addView(createVersionCard());
checkButton = actionButton("Check now", COLOR_ACCENT, Color.WHITE);
checkButton.setOnClickListener(view -> checkForUpdate());
LinearLayout.LayoutParams checkParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
);
checkParams.setMargins(0, dp(16), 0, 0);
content.addView(checkButton, checkParams);
downloadButton = actionButton("Download latest APK", COLOR_WARM, Color.WHITE);
downloadButton.setVisibility(View.GONE);
downloadButton.setOnClickListener(view -> {
String url = latestInfo == null ? UpdateManager.APK_URL : latestInfo.apkUrl;
UpdateManager.openDownload(this, url);
});
LinearLayout.LayoutParams downloadParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(46)
);
downloadParams.setMargins(0, dp(10), 0, 0);
content.addView(downloadButton, downloadParams);
root.addView(scrollView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
root.setOnApplyWindowInsetsListener((view, insets) -> {
content.setPadding(
dp(20),
dp(28) + insets.getSystemWindowInsetTop(),
dp(20),
dp(28) + insets.getSystemWindowInsetBottom()
);
return insets;
});
root.requestApplyInsets();
return root;
}
private View createTopBar() {
LinearLayout topBar = new LinearLayout(this);
topBar.setOrientation(LinearLayout.HORIZONTAL);
topBar.setGravity(Gravity.CENTER_VERTICAL);
TextView label = textView("APP", 12, COLOR_ACCENT, Typeface.BOLD);
topBar.addView(label, new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
));
TextView back = textView("Extras", 15, COLOR_ACCENT, Typeface.BOLD);
back.setGravity(Gravity.CENTER);
back.setPadding(dp(14), 0, dp(14), 0);
back.setBackground(roundedRect(Color.WHITE, dp(8), Color.rgb(219, 226, 222), dp(1)));
back.setOnClickListener(view -> finish());
topBar.addView(back, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
dp(40)
));
return topBar;
}
private View createAutoUpdatesCard() {
LinearLayout card = createCard();
card.setOrientation(LinearLayout.HORIZONTAL);
card.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout copy = new LinearLayout(this);
copy.setOrientation(LinearLayout.VERTICAL);
TextView title = textView("Auto updates", 20, COLOR_INK, Typeface.BOLD);
title.setIncludeFontPadding(false);
copy.addView(title);
TextView subtitle = textView("Check for new builds on startup", 14, COLOR_MUTED, Typeface.NORMAL);
LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
subtitleParams.setMargins(0, dp(6), 0, 0);
copy.addView(subtitle, subtitleParams);
card.addView(copy, new LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
));
Switch autoSwitch = new Switch(this);
autoSwitch.setChecked(UpdateManager.isAutoUpdatesEnabled(this));
autoSwitch.setContentDescription("Auto updates");
autoSwitch.setOnCheckedChangeListener((button, checked) -> {
UpdateManager.setAutoUpdatesEnabled(this, checked);
statusText.setText(checked ? "Startup checks are on." : "Startup checks are off.");
});
card.addView(autoSwitch);
return card;
}
private View createVersionCard() {
LinearLayout card = createCard();
card.setOrientation(LinearLayout.VERTICAL);
TextView title = textView("Version", 20, COLOR_INK, Typeface.BOLD);
title.setIncludeFontPadding(false);
card.addView(title);
installedVersionText = textView("", 15, COLOR_MUTED, Typeface.BOLD);
LinearLayout.LayoutParams installedParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
installedParams.setMargins(0, dp(10), 0, 0);
card.addView(installedVersionText, installedParams);
statusText = textView("Ready to check for updates.", 15, COLOR_MUTED, Typeface.NORMAL);
statusText.setLineSpacing(dp(3), 1.1f);
LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
statusParams.setMargins(0, dp(8), 0, 0);
card.addView(statusText, statusParams);
return card;
}
private void checkForUpdate() {
if (executor == null || executor.isShutdown()) {
return;
}
latestInfo = null;
downloadButton.setVisibility(View.GONE);
checkButton.setEnabled(false);
checkButton.setAlpha(0.62f);
statusText.setText("Checking for updates...");
executor.submit(() -> {
try {
UpdateManager.UpdateInfo info = UpdateManager.fetchLatestUpdate(this);
runOnUiThread(() -> showUpdateResult(info));
} catch (Exception error) {
runOnUiThread(() -> showUpdateError(error));
}
});
}
private void showUpdateResult(UpdateManager.UpdateInfo info) {
latestInfo = info;
checkButton.setEnabled(true);
checkButton.setAlpha(1f);
if (info.updateAvailable) {
String version = info.latestVersionName.isEmpty()
? String.valueOf(info.latestVersionCode)
: info.latestVersionName + " (" + info.latestVersionCode + ")";
statusText.setText("Update available: " + version);
downloadButton.setVisibility(View.VISIBLE);
return;
}
statusText.setText("You are on the latest build: "
+ info.currentVersionName + " (" + info.currentVersionCode + ")");
}
private void showUpdateError(Exception error) {
checkButton.setEnabled(true);
checkButton.setAlpha(1f);
statusText.setText("Could not check for updates. " + cleanMessage(error));
}
private void updateInstalledVersionText() {
try {
installedVersionText.setText("Installed: "
+ UpdateManager.getInstalledVersionName(this)
+ " (" + UpdateManager.getInstalledVersionCode(this) + ")");
} catch (Exception error) {
installedVersionText.setText("Installed version unavailable.");
}
}
private String cleanMessage(Exception error) {
String message = error.getMessage();
if (message == null || message.trim().isEmpty()) {
return "Unknown error.";
}
return message.trim();
}
private LinearLayout createCard() {
LinearLayout card = new LinearLayout(this);
card.setPadding(dp(18), dp(16), dp(18), dp(16));
card.setBackground(roundedRect(COLOR_CARD, dp(8), Color.rgb(229, 232, 230), dp(1)));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
card.setElevation(dp(1));
}
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
params.setMargins(0, 0, 0, dp(14));
card.setLayoutParams(params);
return card;
}
private TextView actionButton(String text, int backgroundColor, int textColor) {
TextView button = textView(text, 15, textColor, Typeface.BOLD);
button.setGravity(Gravity.CENTER);
button.setPadding(dp(14), 0, dp(14), 0);
button.setBackground(roundedRect(backgroundColor, dp(8), Color.TRANSPARENT, 0));
return button;
}
private TextView textView(String text, int sizeSp, int color, int style) {
TextView textView = new TextView(this);
textView.setText(text);
textView.setTextSize(sizeSp);
textView.setTextColor(color);
textView.setTypeface(Typeface.DEFAULT, style);
return textView;
}
private GradientDrawable roundedRect(int color, int radius, int strokeColor, int strokeWidth) {
GradientDrawable drawable = new GradientDrawable();
drawable.setShape(GradientDrawable.RECTANGLE);
drawable.setColor(color);
drawable.setCornerRadius(radius);
if (strokeWidth > 0) {
drawable.setStroke(strokeWidth, strokeColor);
}
return drawable;
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -0,0 +1,181 @@
package com.bibliadiaria.app;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
final class UpdateManager {
static final String PREFS_NAME = "preferencias_lectura";
static final String KEY_AUTO_UPDATES = "auto_updates_enabled";
static final String LATEST_JSON_URL =
"https://github.com/jahruz67/Bible-Daily/releases/download/latest/latest.json";
static final String APK_URL =
"https://github.com/jahruz67/Bible-Daily/releases/download/latest/app-debug.apk";
private UpdateManager() {
}
static boolean isAutoUpdatesEnabled(Context context) {
return preferences(context).getBoolean(KEY_AUTO_UPDATES, true);
}
static void setAutoUpdatesEnabled(Context context, boolean enabled) {
preferences(context).edit().putBoolean(KEY_AUTO_UPDATES, enabled).apply();
}
static UpdateInfo fetchLatestUpdate(Context context) throws Exception {
String jsonText = download(LATEST_JSON_URL, "application/json");
JSONObject json = new JSONObject(jsonText);
int latestVersionCode = json.optInt("versionCode", 0);
if (latestVersionCode <= 0) {
throw new IOException("Update metadata did not include a valid versionCode.");
}
long currentVersionCode = getInstalledVersionCode(context);
String latestVersionName = json.optString("versionName", "");
String apkUrl = json.optString("apkUrl", APK_URL);
String builtAt = json.optString("builtAt", "");
String commit = json.optString("commit", "");
return new UpdateInfo(
currentVersionCode,
getInstalledVersionName(context),
latestVersionCode,
latestVersionName,
apkUrl.isEmpty() ? APK_URL : apkUrl,
builtAt,
commit,
latestVersionCode > currentVersionCode
);
}
static long getInstalledVersionCode(Context context) throws PackageManager.NameNotFoundException {
PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return info.getLongVersionCode();
}
return info.versionCode;
}
static String getInstalledVersionName(Context context) {
try {
PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
if (info.versionName == null || info.versionName.trim().isEmpty()) {
return "unknown";
}
return info.versionName;
} catch (PackageManager.NameNotFoundException error) {
return "unknown";
}
}
static void showUpdateDialog(Activity activity, UpdateInfo info) {
if (activity.isFinishing()) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && activity.isDestroyed()) {
return;
}
String version = info.latestVersionName.isEmpty()
? String.valueOf(info.latestVersionCode)
: info.latestVersionName + " (" + info.latestVersionCode + ")";
new AlertDialog.Builder(activity)
.setTitle("Update available")
.setMessage("Version " + version + " is ready to download.")
.setPositiveButton("Download", (dialog, which) -> openDownload(activity, info.apkUrl))
.setNegativeButton("Later", null)
.show();
}
static void openDownload(Context context, String apkUrl) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkUrl));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
private static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
private static String download(String urlText, String acceptHeader) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(urlText).openConnection();
connection.setConnectTimeout(12000);
connection.setReadTimeout(12000);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("User-Agent", "BibliaDiaria/1.0 Android");
connection.setRequestProperty("Accept", acceptHeader);
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
throw new IOException("Update server responded with code " + responseCode + ".");
}
try (InputStream stream = connection.getInputStream()) {
return readStream(stream);
} finally {
connection.disconnect();
}
}
private static String readStream(InputStream stream) throws IOException {
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line).append('\n');
}
}
return builder.toString();
}
static final class UpdateInfo {
final long currentVersionCode;
final String currentVersionName;
final int latestVersionCode;
final String latestVersionName;
final String apkUrl;
final String builtAt;
final String commit;
final boolean updateAvailable;
UpdateInfo(
long currentVersionCode,
String currentVersionName,
int latestVersionCode,
String latestVersionName,
String apkUrl,
String builtAt,
String commit,
boolean updateAvailable
) {
this.currentVersionCode = currentVersionCode;
this.currentVersionName = currentVersionName;
this.latestVersionCode = latestVersionCode;
this.latestVersionName = latestVersionName;
this.apkUrl = apkUrl;
this.builtAt = builtAt;
this.commit = commit;
this.updateAvailable = updateAvailable;
}
}
}