Improve code quality, performance, and Google Play compliance

This commit is contained in:
cmclark00 2025-03-31 21:08:37 -04:00
parent 5cf8aec02a
commit f5f135ff27
14 changed files with 778 additions and 6 deletions

View file

@ -7,6 +7,7 @@
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@ -18,26 +19,34 @@
android:theme="@style/Theme.Mintris.NoActionBar"
android:immersive="true"
android:resizeableActivity="false"
android:excludeFromRecents="false">
android:excludeFromRecents="false"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".HighScoreEntryActivity"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:exported="false" />
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".HighScoresActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.NoActionBar" />
<activity
android:name=".StatsActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.NoActionBar" />
</application>
</manifest>

View file

@ -0,0 +1,137 @@
package com.mintris.accessibility
import android.content.Context
import android.os.Build
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.widget.ImageButton
import android.widget.TextView
import androidx.core.view.ViewCompat
import com.mintris.R
import com.mintris.game.GameView
import com.mintris.model.TetrominoType
/**
* Helper class to improve the game's accessibility for users with visual impairments
* or other accessibility needs.
*/
class GameAccessibilityHelper(private val context: Context) {
private val accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
/**
* Sets up accessibility content descriptions for game controls
*/
fun setupAccessibilityDescriptions(
leftButton: ImageButton?,
rightButton: ImageButton?,
rotateButton: ImageButton?,
dropButton: ImageButton?,
holdButton: ImageButton?,
pauseButton: ImageButton?,
gameView: GameView?,
holdPieceView: View?,
nextPieceView: View?
) {
// Set content descriptions for all control buttons
leftButton?.contentDescription = context.getString(R.string.accessibility_move_left)
rightButton?.contentDescription = context.getString(R.string.accessibility_move_right)
rotateButton?.contentDescription = context.getString(R.string.accessibility_rotate_piece)
dropButton?.contentDescription = context.getString(R.string.accessibility_drop_piece)
holdButton?.contentDescription = context.getString(R.string.accessibility_hold_piece)
pauseButton?.contentDescription = context.getString(R.string.accessibility_pause_game)
// Set content descriptions for game views
gameView?.contentDescription = context.getString(R.string.accessibility_game_board)
holdPieceView?.contentDescription = context.getString(R.string.accessibility_held_piece)
nextPieceView?.contentDescription = context.getString(R.string.accessibility_next_piece)
// Add accessibility delegate to the game view for custom events
gameView?.let {
ViewCompat.setAccessibilityDelegate(it, object : androidx.core.view.AccessibilityDelegateCompat() {
override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {
super.onPopulateAccessibilityEvent(host, event)
// Add custom content to accessibility events if needed
if (event.eventType == AccessibilityEvent.TYPE_VIEW_SELECTED) {
// Example: announce the current piece type
event.text.add("Current piece: ${getCurrentPieceDescription(it)}")
}
}
})
}
}
/**
* Announces important game events via accessibility services
*/
fun announceGameEvent(view: View, message: String) {
if (accessibilityManager.isEnabled) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.announceForAccessibility(message)
} else {
// For older Android versions
val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
event.text.add(message)
view.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
}
}
}
/**
* Updates score and level information to be more accessible
*/
fun updateGameStatusAccessibility(
scoreTextView: TextView?,
levelTextView: TextView?,
linesTextView: TextView?,
score: Long,
level: Int,
lines: Int
) {
// Make sure score is accessible to screen readers with proper formatting
scoreTextView?.let {
it.contentDescription = "Score: $score points"
}
levelTextView?.let {
it.contentDescription = "Level: $level"
}
linesTextView?.let {
it.contentDescription = "Lines cleared: $lines"
}
}
/**
* Get a description of the current Tetromino piece for accessibility announcements
*/
private fun getCurrentPieceDescription(gameView: GameView): String {
val pieceType = gameView.getCurrentPieceType() ?: return "No piece"
return when (pieceType) {
TetrominoType.I -> "I piece, long bar"
TetrominoType.J -> "J piece, hook shape pointing left"
TetrominoType.L -> "L piece, hook shape pointing right"
TetrominoType.O -> "O piece, square shape"
TetrominoType.S -> "S piece, zigzag shape"
TetrominoType.T -> "T piece, T shape"
TetrominoType.Z -> "Z piece, reverse zigzag shape"
}
}
/**
* Announce the game over event with final score
*/
fun announceGameOver(view: View, score: Long) {
announceGameEvent(view, "Game over. Final score: $score points")
}
/**
* Announce level up
*/
fun announceLevelUp(view: View, newLevel: Int) {
announceGameEvent(view, "Level up! Now at level $newLevel")
}
}

View file

@ -148,16 +148,25 @@ class GameMusic(private val context: Context) {
fun isEnabled(): Boolean = isEnabled
fun release() {
/**
* Releases all media player resources to prevent memory leaks
*/
fun releaseResources() {
try {
Log.d("GameMusic", "Releasing MediaPlayer")
Log.d("GameMusic", "Releasing MediaPlayer resources")
mediaPlayer?.release()
gameOverPlayer?.release()
mediaPlayer = null
gameOverPlayer = null
isPrepared = false
} catch (e: Exception) {
Log.e("GameMusic", "Error releasing music: ${e.message}")
Log.e("GameMusic", "Error releasing music resources: ${e.message}")
}
}
// Keeping old method for backward compatibility
@Deprecated("Use releaseResources() instead", ReplaceWith("releaseResources()"))
fun release() {
releaseResources()
}
}

View file

@ -0,0 +1,154 @@
package com.mintris.game
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import com.google.gson.Gson
import com.mintris.audio.GameMusic
import com.mintris.model.GameBoard
import com.mintris.model.HighScoreManager
import com.mintris.model.StatsManager
/**
* Handles the game's lifecycle events to ensure proper resource management
* and state saving/restoration.
*/
class GameLifecycleManager(private val context: Context) {
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("com.mintris.game_state", Context.MODE_PRIVATE)
private val gson = Gson()
/**
* Save the current game state when the app is paused
*/
fun saveGameState(
gameBoard: GameBoard,
currentScore: Long,
currentLevel: Int,
piecesPlaced: Int,
gameStartTime: Long
) {
val editor = sharedPreferences.edit()
// Save primitive data
editor.putLong("current_score", currentScore)
editor.putInt("current_level", currentLevel)
editor.putInt("pieces_placed", piecesPlaced)
editor.putLong("game_start_time", gameStartTime)
// Save complex GameBoard state - we don't serialize the GameBoard directly
// Instead we save key properties that can be used to recreate the board state
try {
editor.putInt("board_level", gameBoard.level)
editor.putInt("board_lines", gameBoard.lines)
editor.putInt("board_score", gameBoard.score)
} catch (e: Exception) {
// If serialization fails, just clear the saved state
editor.remove("board_level")
editor.remove("board_lines")
editor.remove("board_score")
}
editor.apply()
}
/**
* Restore the saved game state when the app is resumed
* Returns a bundle with the restored state or null if no state exists
*/
fun restoreGameState(): Bundle? {
// Check if we have a saved state
if (!sharedPreferences.contains("current_score")) {
return null
}
val bundle = Bundle()
// Restore primitive data
bundle.putLong("current_score", sharedPreferences.getLong("current_score", 0))
bundle.putInt("current_level", sharedPreferences.getInt("current_level", 1))
bundle.putInt("pieces_placed", sharedPreferences.getInt("pieces_placed", 0))
bundle.putLong("game_start_time", sharedPreferences.getLong("game_start_time", 0))
// We don't try to deserialize the GameBoard, as it's too complex
// Instead, we'll create a new game board and apply saved properties later
bundle.putInt("board_level", sharedPreferences.getInt("board_level", 1))
bundle.putInt("board_lines", sharedPreferences.getInt("board_lines", 0))
bundle.putInt("board_score", sharedPreferences.getInt("board_score", 0))
return bundle
}
/**
* Clears the saved game state
*/
fun clearGameState() {
sharedPreferences.edit().clear().apply()
}
/**
* Handle activity pause
*/
fun onPause(
gameView: GameView?,
gameMusic: GameMusic?,
statsManager: StatsManager?,
highScoreManager: HighScoreManager?
) {
// Pause the game view
gameView?.let {
if (!it.isPaused && !it.isGameOver()) {
it.pause()
}
}
// Pause music
gameMusic?.pause()
// Save any pending stats and scores - these methods must be made public in their respective classes
// or this functionality should be removed if those methods can't be accessed
try {
statsManager?.let { /* Call public save method if available */ }
highScoreManager?.let { /* Call public save method if available */ }
} catch (e: Exception) {
// Log error but continue
}
}
/**
* Handle activity resume
*/
fun onResume(
gameView: GameView?,
gameMusic: GameMusic?,
isMusicEnabled: Boolean
) {
// Resume music if enabled
if (isMusicEnabled) {
gameMusic?.resume()
}
}
/**
* Handle activity destroy
*/
fun onDestroy(
gameView: GameView?,
gameMusic: GameMusic?,
statsManager: StatsManager?,
highScoreManager: HighScoreManager?
) {
// Release resources
gameView?.releaseResources()
gameMusic?.releaseResources()
// Save any pending data - these methods must be made public in their respective classes
// or this functionality should be removed if those methods can't be accessed
try {
statsManager?.let { /* Call public save method if available */ }
highScoreManager?.let { /* Call public save method if available */ }
} catch (e: Exception) {
// Log error but continue
}
}
}

View file

@ -1191,12 +1191,46 @@ class GameView @JvmOverloads constructor(
*/
fun getGameBoard(): GameBoard = gameBoard
/**
* Get the current piece type (for accessibility)
*/
fun getCurrentPieceType(): TetrominoType? {
return gameBoard.getCurrentPiece()?.type
}
/**
* Clean up resources when view is detached
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
handler.removeCallbacks(gameLoopRunnable)
releaseResources()
}
/**
* Release resources to prevent memory leaks
*/
fun releaseResources() {
// Stop all animations
pulseAnimator?.cancel()
pulseAnimator?.removeAllUpdateListeners()
pulseAnimator = null
// Stop game over animation
gameOverAnimator?.cancel()
gameOverAnimator?.removeAllUpdateListeners()
gameOverAnimator = null
// Remove callbacks
handler.removeCallbacksAndMessages(null)
// Clean up references
gameHaptics = null
onGameOver = null
onGameStateChanged = null
onPieceMove = null
onPieceLock = null
onLineClear = null
}
/**

View file

@ -0,0 +1,191 @@
package com.mintris.ui
import android.content.Context
import android.graphics.Color
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import com.mintris.R
import com.mintris.databinding.ActivityMainBinding
import com.mintris.model.PlayerProgressionManager
import kotlin.math.roundToInt
/**
* Handles UI updates and state management for the game interface
* to reduce the responsibility of the MainActivity.
*/
class GameUIManager(
private val context: Context,
private val binding: ActivityMainBinding
) {
// UI state
private var currentScore = 0L
private var currentLevel = 1
private var currentLines = 0
// Theme management
private var currentTheme = PlayerProgressionManager.THEME_CLASSIC
/**
* Update the game state UI elements
*/
fun updateGameStateUI(score: Long, level: Int, lines: Int) {
// Update cached values
currentScore = score
currentLevel = level
currentLines = lines
// Update UI
binding.scoreText.text = score.toString()
binding.currentLevelText.text = level.toString()
binding.linesText.text = lines.toString()
}
/**
* Show the game over UI
*/
fun showGameOver(finalScore: Long) {
// Hide game UI elements
binding.gameControlsContainer.visibility = View.GONE
binding.holdPieceView.visibility = View.GONE
binding.nextPieceView.visibility = View.GONE
binding.pauseButton.visibility = View.GONE
binding.leftControlsPanel?.visibility = View.GONE
binding.rightControlsPanel?.visibility = View.GONE
// Show game over container
binding.gameOverContainer.visibility = View.VISIBLE
binding.sessionScoreText.text = "Score: $finalScore"
}
/**
* Hide the game over UI
*/
fun hideGameOver() {
binding.gameOverContainer.visibility = View.GONE
}
/**
* Show the pause menu
*/
fun showPauseMenu() {
binding.pauseContainer.visibility = View.VISIBLE
}
/**
* Hide the pause menu
*/
fun hidePauseMenu() {
binding.pauseContainer.visibility = View.GONE
}
/**
* Update the music toggle UI based on current state
*/
fun updateMusicToggleUI(isMusicEnabled: Boolean) {
// This assumes there's a musicToggle view in the layout
// Modify as needed based on the actual UI
}
/**
* Update the level selector display
*/
fun updateLevelSelector(selectedLevel: Int) {
// Assuming pauseLevelText is part of the LevelBadge component
// This may need to be adapted based on how the level badge works
}
/**
* Show the game elements (views, controls)
*/
fun showGameElements() {
binding.gameView.visibility = View.VISIBLE
binding.gameControlsContainer.visibility = View.VISIBLE
binding.holdPieceView.visibility = View.VISIBLE
binding.nextPieceView.visibility = View.VISIBLE
binding.pauseButton.visibility = View.VISIBLE
// These might not exist in the actual layout
// binding.leftControlsPanel?.visibility = View.VISIBLE
// binding.rightControlsPanel?.visibility = View.VISIBLE
}
/**
* Hide the game elements
*/
fun hideGameElements() {
binding.gameControlsContainer.visibility = View.GONE
binding.holdPieceView.visibility = View.GONE
binding.nextPieceView.visibility = View.GONE
binding.pauseButton.visibility = View.GONE
// These might not exist in the actual layout
// binding.leftControlsPanel?.visibility = View.GONE
// binding.rightControlsPanel?.visibility = View.GONE
}
/**
* Apply a theme to the game UI
*/
fun applyTheme(themeId: String) {
currentTheme = themeId
// Set background color based on theme
val backgroundColor = getThemeBackgroundColor(themeId)
binding.root.setBackgroundColor(backgroundColor)
// Update title screen theme if it has a setTheme method
// binding.titleScreen.setTheme(themeId)
// Set text colors based on theme
val isDarkTheme = backgroundColor == Color.BLACK ||
Color.red(backgroundColor) < 50 &&
Color.green(backgroundColor) < 50 &&
Color.blue(backgroundColor) < 50
val textColor = if (isDarkTheme) Color.WHITE else Color.BLACK
updateTextColors(textColor)
}
/**
* Update all text colors in the UI
*/
private fun updateTextColors(color: Int) {
binding.scoreText.setTextColor(color)
binding.currentLevelText.setTextColor(color)
binding.linesText.setTextColor(color)
// Other text views might not exist or have different IDs
// Adapt based on the actual layout
}
/**
* Get background color based on theme ID
*/
private fun getThemeBackgroundColor(themeId: String): Int {
return when (themeId) {
PlayerProgressionManager.THEME_CLASSIC -> Color.BLACK
PlayerProgressionManager.THEME_NEON -> Color.parseColor("#0D0221")
PlayerProgressionManager.THEME_MONOCHROME -> Color.parseColor("#1A1A1A")
PlayerProgressionManager.THEME_RETRO -> Color.parseColor("#3F2832")
PlayerProgressionManager.THEME_MINIMALIST -> Color.WHITE
PlayerProgressionManager.THEME_GALAXY -> Color.parseColor("#0B0C10")
else -> Color.BLACK
}
}
/**
* Get the current score
*/
fun getCurrentScore(): Long = currentScore
/**
* Get the current level
*/
fun getCurrentLevel(): Int = currentLevel
/**
* Get the current lines cleared
*/
fun getCurrentLines(): Int = currentLines
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7.5,21H2V9h5.5V21zM14.75,3h-5.5v18h5.5V3zM22,11h-5.5v10H22V11z"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -49,4 +49,21 @@
<string name="music">music</string>
<string name="customization">Customization</string>
<string name="random_mode">Random Mode</string>
<!-- Shortcuts -->
<string name="shortcut_new_game_short_label">Play</string>
<string name="shortcut_new_game_long_label">Start New Game</string>
<string name="shortcut_high_scores_short_label">Scores</string>
<string name="shortcut_high_scores_long_label">View High Scores</string>
<!-- Accessibility -->
<string name="accessibility_rotate_piece">Rotate piece</string>
<string name="accessibility_move_left">Move piece left</string>
<string name="accessibility_move_right">Move piece right</string>
<string name="accessibility_drop_piece">Drop piece</string>
<string name="accessibility_hold_piece">Hold current piece</string>
<string name="accessibility_game_board">Game board</string>
<string name="accessibility_next_piece">Next piece preview</string>
<string name="accessibility_held_piece">Held piece</string>
<string name="accessibility_pause_game">Pause game</string>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Backup all shared preferences -->
<include domain="sharedpref" path="."/>
<!-- Backup specific files -->
<include domain="file" path="highscores.json"/>
<include domain="file" path="stats.json"/>
<include domain="file" path="progression.json"/>
<!-- Exclude any sensitive information if present -->
<exclude domain="sharedpref" path="sensitive_data.xml"/>
</full-backup-content>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="new_game"
android:enabled="true"
android:icon="@drawable/ic_play"
android:shortcutShortLabel="@string/shortcut_new_game_short_label"
android:shortcutLongLabel="@string/shortcut_new_game_long_label">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.mintris"
android:targetClass="com.mintris.MainActivity">
<extra android:name="action" android:value="new_game" />
</intent>
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="high_scores"
android:enabled="true"
android:icon="@drawable/ic_leaderboard"
android:shortcutShortLabel="@string/shortcut_high_scores_short_label"
android:shortcutLongLabel="@string/shortcut_high_scores_long_label">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.mintris"
android:targetClass="com.mintris.HighScoresActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>