diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index d7f9948..da5d00d 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -26,18 +26,51 @@ jobs: - name: Set up Gradle 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 run: chmod +x gradlew - 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 < + + diff --git a/app/src/main/java/com/bibliadiaria/app/ExtrasActivity.java b/app/src/main/java/com/bibliadiaria/app/ExtrasActivity.java index 32e503e..c298f84 100644 --- a/app/src/main/java/com/bibliadiaria/app/ExtrasActivity.java +++ b/app/src/main/java/com/bibliadiaria/app/ExtrasActivity.java @@ -1,6 +1,7 @@ package com.bibliadiaria.app; import android.app.Activity; +import android.content.Intent; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; @@ -153,6 +154,18 @@ public class ExtrasActivity extends Activity { 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); home.setGravity(Gravity.CENTER); home.setPadding(dp(14), 0, dp(14), 0); diff --git a/app/src/main/java/com/bibliadiaria/app/MainActivity.java b/app/src/main/java/com/bibliadiaria/app/MainActivity.java index 18f570d..c6215d5 100644 --- a/app/src/main/java/com/bibliadiaria/app/MainActivity.java +++ b/app/src/main/java/com/bibliadiaria/app/MainActivity.java @@ -91,6 +91,7 @@ public class MainActivity extends Activity { setContentView(createScreen()); loadToday(); + checkForUpdateOnStartup(); } @Override @@ -600,6 +601,23 @@ public class MainActivity extends Activity { 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() { loadDate(selectedDateCalendar.getTime()); } diff --git a/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java b/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java new file mode 100644 index 0000000..fd64da9 --- /dev/null +++ b/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java @@ -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); + } +} diff --git a/app/src/main/java/com/bibliadiaria/app/UpdateManager.java b/app/src/main/java/com/bibliadiaria/app/UpdateManager.java new file mode 100644 index 0000000..1b972df --- /dev/null +++ b/app/src/main/java/com/bibliadiaria/app/UpdateManager.java @@ -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; + } + } +}