diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e1ca4a6..0dff777 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,8 +6,11 @@
that other apps created.
-->
+
+
requestPermissionLauncher = registerForActivityResult(
+ new ActivityResultContracts.RequestPermission(), isGranted -> {
+ if (isGranted) {
+ mGetContent.launch("image/*");
+ } else {
+ Toast.makeText(this, "Permission denied to read images", Toast.LENGTH_SHORT).show();
+ }
+ });
+
/**
* Called when the activity is first created. Initializes UI and loads existing player if present.
*
@@ -207,7 +221,7 @@ public class AddPlayerActivity extends AppCompatActivity {
mCropOverlay = findViewById(R.id.cropOverlay);
// Set up click listeners
- mProfilePictureView.setOnClickListener(v -> mGetContent.launch("image/*"));
+ mProfilePictureView.setOnClickListener(v -> checkForPermissionAndLaunchImagePicker());
mSaveButton.setOnClickListener(v -> savePlayer());
if (mBtnDelete != null) {
mBtnDelete.setOnClickListener(v -> deletePlayer());
@@ -216,6 +230,32 @@ public class AddPlayerActivity extends AppCompatActivity {
findViewById(R.id.btnCancelCrop).setOnClickListener(v -> exitCropMode());
}
+ private void checkForPermissionAndLaunchImagePicker() {
+ final String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ ? Manifest.permission.READ_MEDIA_IMAGES
+ : Manifest.permission.READ_EXTERNAL_STORAGE;
+
+ if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
+ // Already have permission
+ mGetContent.launch("image/*");
+ } else if (shouldShowRequestPermissionRationale(permission)) {
+ // Explain to the user why the permission is needed before requesting
+ showRationaleDialog(permission);
+ } else {
+ // Directly request the permission
+ requestPermissionLauncher.launch(permission);
+ }
+ }
+
+ private void showRationaleDialog(final String permission) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.txt_permission_hint_title)
+ .setMessage(R.string.txt_permission_hint_description)
+ .setPositiveButton(R.string.txt_permission_hint_button_ok, (d, w) -> requestPermissionLauncher.launch(permission))
+ .setNegativeButton(R.string.txt_permission_hint_button_cancel, null)
+ .show();
+ }
+
/**
* Initializes gesture detectors to handle pinch-to-zoom and pan gestures.
*/
@@ -436,7 +476,9 @@ public class AddPlayerActivity extends AppCompatActivity {
}
/**
- * Deletes the selected player from the database.
+ * Deletes the currently loaded player from the database.
+ * Shows confirmation toast and closes the activity upon successful deletion.
+ * Runs database operation on background thread. Does nothing if no player is loaded.
*/
private void deletePlayer() {
if (mExistingPlayer == null) return;
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
index 1641ce3..66168b1 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
@@ -4,7 +4,6 @@ import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.Color;
-import android.media.SoundPool;
import android.os.Build;
import android.os.Bundle;
import android.os.VibrationEffect;
@@ -26,10 +25,8 @@ import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.aldo.apps.ochecompanion.database.AppDatabase;
-import com.aldo.apps.ochecompanion.database.objects.Match;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
-import com.aldo.apps.ochecompanion.utils.CheckoutConstants;
import com.aldo.apps.ochecompanion.utils.CheckoutEngine;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
import com.aldo.apps.ochecompanion.utils.SoundEngine;
@@ -169,7 +166,7 @@ public class GameActivity extends AppCompatActivity {
/**
* Array of three TextViews showing darts thrown in current turn.
*/
- private TextView[] tvDartPills = new TextView[3];
+ private final TextView[] tvDartPills = new TextView[3];
/**
* GridLayout container holding numeric keyboard buttons (1-20).
@@ -220,9 +217,7 @@ public class GameActivity extends AppCompatActivity {
new Thread(() -> {
final List allAvailablePlayers = AppDatabase.getDatabase(GameActivity.this).playerDao().getAllPlayers();
Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]");
- runOnUiThread(() -> {
- setupGame(allAvailablePlayers);
- });
+ runOnUiThread(() -> setupGame(allAvailablePlayers));
}).start();
}
@@ -395,6 +390,16 @@ public class GameActivity extends AppCompatActivity {
}
}
+ /**
+ * Updates player statistics in the database after a turn.
+ * Tracks darts thrown, points made, missed darts, and updates career average.
+ * Runs on background thread to avoid blocking UI.
+ *
+ * @param active Current player's game state
+ * @param dartsThrown Number of darts thrown this turn
+ * @param pointsMade Total points scored this turn
+ * @param wasBust Whether the turn resulted in a bust
+ */
private void updatePlayerStats(GameActivity.X01State active, int dartsThrown, int pointsMade, boolean wasBust) {
new Thread(() -> {
final Player player = active.player;
@@ -411,7 +416,7 @@ public class GameActivity extends AppCompatActivity {
// Calculate career average: total points / total darts thrown
final long totalDarts = playerStats.getDartsThrown();
if (totalDarts > 0) {
- player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts;
+ player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts * 3;
} else {
player.careerAverage = 0.0;
}
@@ -443,7 +448,15 @@ public class GameActivity extends AppCompatActivity {
final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createOneShot(300, VibrationEffect.DEFAULT_AMPLITUDE));
+ Log.d(TAG, "submitTurn: Pattern vibration");
+ // Pattern that should match the 180 shout.
+ long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200};
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));
+ } else if (vibrator != null) {
+ Log.d(TAG, "submitTurn: Vibrating legacy mode");
+ vibrator.vibrate(500);
+ } else {
+ Log.e(TAG, "submitTurn: Vibrator not available");
}
mSoundEngine.playOneHundredAndEightySound();
}
@@ -601,9 +614,11 @@ public class GameActivity extends AppCompatActivity {
/**
* Handles win condition when a player finishes on zero with a double.
- * Displays win toast and finishes activity.
+ * Updates statistics, displays win toast, and plays celebration animation.
*
* @param winner X01State of the winning player
+ * @param dartsThrown Number of darts thrown in the winning turn
+ * @param pointsMade Points scored in the winning turn
*/
private void handleWin(final X01State winner, final int dartsThrown, final int pointsMade) {
updatePlayerStats(winner, dartsThrown, pointsMade, false);
@@ -619,6 +634,12 @@ public class GameActivity extends AppCompatActivity {
// - Offer rematch
}
+ /**
+ * Plays confetti animation and displays winner's name overlay.
+ * Shows full-screen dimmer with celebratory confetti effect.
+ *
+ * @param winnerName Name of the winning player to display
+ */
private void playWinnerAnimation(final String winnerName) {
final KonfettiView konfettiView = findViewById(R.id.konfetti_view);
final View dimmerLayout = findViewById(R.id.dimmer);
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
index f9421b6..fd46fbf 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
@@ -4,7 +4,6 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
-import android.view.View;
import android.widget.TextView;
import androidx.activity.EdgeToEdge;
@@ -20,6 +19,7 @@ import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Match;
import com.aldo.apps.ochecompanion.ui.MatchRecapView;
+import com.aldo.apps.ochecompanion.ui.QuickStartButton;
import com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
import com.aldo.apps.ochecompanion.utils.UIConstants;
@@ -77,7 +77,11 @@ public class MainMenuActivity extends AppCompatActivity {
return insets;
});
- findViewById(R.id.quick_start_btn).setOnClickListener(v -> quickStart());
+ final QuickStartButton quickStartBtn = findViewById(R.id.quick_start_btn);
+ final String defaultGameMode = mSettingsPref.getString(getString(R.string.pref_desc_standard_game_mode),
+ getString(R.string.pref_game_mode_501_value));
+ quickStartBtn.setSubText(defaultGameMode);
+ quickStartBtn.setOnClickListener(v -> quickStart());
findViewById(R.id.btnSettings).setOnClickListener(v -> launchSettings());
// Set up match recap view with test data functionality
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java b/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java
index c9772d5..2bb74b0 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java
@@ -1,14 +1,21 @@
package com.aldo.apps.ochecompanion.utils;
+import static android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION;
+import static android.media.AudioAttributes.USAGE_GAME;
+
import android.content.Context;
+import android.media.AudioAttributes;
import android.media.SoundPool;
+import android.os.Build;
import android.util.Log;
import com.aldo.apps.ochecompanion.R;
-import java.lang.ref.WeakReference;
-import java.util.SimpleTimeZone;
-
+/**
+ * Singleton sound engine for managing game audio effects.
+ * Uses Android SoundPool API for low-latency sound playback during gameplay.
+ * Supports winner celebration, bust notifications, and perfect score achievements.
+ */
public final class SoundEngine {
/**
@@ -16,30 +23,80 @@ public final class SoundEngine {
*/
private static final String TAG = "SoundEngine";
- private Context mContext;
+ /**
+ * Application context used for audio operations.
+ * On Android R+, uses attribution context for proper audio tracking.
+ */
+ private final Context mContext;
+ /**
+ * Singleton instance of the SoundEngine.
+ */
private static SoundEngine sInstance;
+ /**
+ * Android SoundPool for low-latency audio playback.
+ * Configured with game audio attributes and max 5 concurrent streams.
+ */
private final SoundPool mSoundPool;
+ /**
+ * Flag indicating whether all sound resources have been loaded.
+ * Set to true by the OnLoadCompleteListener once all sounds are ready.
+ */
private boolean mIsReady;
- private int mWinnerSoundId;
+ /**
+ * Sound ID for the winner celebration audio effect.
+ * Loaded from R.raw.winner resource.
+ */
+ private final int mWinnerSoundId;
- private int mBustedSoundId;
+ /**
+ * Sound ID for the bust notification audio effect.
+ * Loaded from R.raw.busted resource.
+ */
+ private final int mBustedSoundId;
- private int m180SoundId;
+ /**
+ * Sound ID for the perfect score (180) celebration audio effect.
+ * Loaded from R.raw.onehundredandeighty resource.
+ */
+ private final int m180SoundId;
+ /**
+ * Private constructor for singleton pattern.
+ * Initializes SoundPool with game audio attributes and preloads sound effects.
+ *
+ * @param context Application context for loading sound resources
+ */
private SoundEngine(final Context context) {
- mContext = context;
- mSoundPool = new SoundPool.Builder().setMaxStreams(5).build();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ mContext = context.createAttributionContext("oche_gameplay");
+ } else {
+ mContext = context;
+ }
+ mSoundPool = new SoundPool.Builder()
+ .setMaxStreams(5)
+ .setAudioAttributes(new AudioAttributes.Builder()
+ .setUsage(USAGE_GAME)
+ .setContentType(CONTENT_TYPE_SONIFICATION)
+ .build())
+ .build();
mSoundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> mIsReady = true);
- mWinnerSoundId = mSoundPool.load(context, R.raw.winner, 1);
- mBustedSoundId = mSoundPool.load(context, R.raw.busted, 1);
- m180SoundId = mSoundPool.load(context, R.raw.onehundredandeighty, 1);
+ mWinnerSoundId = mSoundPool.load(context.getApplicationContext(), R.raw.winner, 1);
+ mBustedSoundId = mSoundPool.load(context.getApplicationContext(), R.raw.busted, 1);
+ m180SoundId = mSoundPool.load(context.getApplicationContext(), R.raw.onehundredandeighty, 1);
}
+ /**
+ * Gets the singleton SoundEngine instance.
+ * Creates instance on first call using the provided context.
+ *
+ * @param context Application context for initialization
+ * @return Singleton SoundEngine instance
+ */
public static SoundEngine getInstance(final Context context) {
if (sInstance == null) {
sInstance = new SoundEngine(context);
@@ -47,6 +104,10 @@ public final class SoundEngine {
return sInstance;
}
+ /**
+ * Plays the winner celebration sound effect.
+ * Only plays if sound engine has finished loading resources.
+ */
public void playWinnerSound() {
if (mIsReady) {
mSoundPool.play(mWinnerSoundId, 1.0f, 1.0f, 0, 0, 1.0f);
@@ -55,6 +116,10 @@ public final class SoundEngine {
}
}
+ /**
+ * Plays the bust notification sound effect.
+ * Only plays if sound engine has finished loading resources.
+ */
public void playBustedSound() {
if (mIsReady) {
mSoundPool.play(mBustedSoundId, 1.0f, 1.0f, 0, 0, 1.0f);
@@ -63,6 +128,10 @@ public final class SoundEngine {
}
}
+ /**
+ * Plays the 180 score notification sound effect.
+ * Only plays if sound engine has finished loading resources.
+ */
public void playOneHundredAndEightySound() {
if (mIsReady) {
mSoundPool.play(m180SoundId, 1.0f, 1.0f, 0, 0, 1.0f);
diff --git a/app/src/main/res/anim/shake.xml b/app/src/main/res/anim/shake.xml
index f3d3989..f7343f4 100644
--- a/app/src/main/res/anim/shake.xml
+++ b/app/src/main/res/anim/shake.xml
@@ -1,8 +1,8 @@
\ No newline at end of file
+ android:interpolator="@android:anim/cycle_interpolator" />
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 520c6d8..b789a9a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,10 @@
Update Squad
Cancel
Confirm Crop
+ Image Permission required
+ This app needs access to your images to set a custom profile picture
+ OK
+ Cancel
Single
@@ -32,9 +36,9 @@
day_night_mode
standard_game_mode
- 701
- 501
- 301
+ Standard 701 - Double Out
+ Standard 501 - Double Out
+ Standard 301 - Double Out
Cricket
Day/Night Mode
Standard Game Mode
@@ -61,4 +65,7 @@
Automatically download attachments for incoming emails
Only download attachments when manually requested
+
+
+ "Oche Game Effects"
\ No newline at end of file