diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72d86e6..39cb9ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jlleitschuh.gradle.ktlint") + id("com.google.devtools.ksp") } android { @@ -40,11 +41,18 @@ android { } } +val roomVersion = "2.5.1" + dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.8.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.room:room-runtime:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/androidTest/java/com/ifmo/balda/MainActivityTest.kt b/app/src/androidTest/java/com/ifmo/balda/MainActivityTest.kt index f8d6d48..cb16d71 100644 --- a/app/src/androidTest/java/com/ifmo/balda/MainActivityTest.kt +++ b/app/src/androidTest/java/com/ifmo/balda/MainActivityTest.kt @@ -1,5 +1,7 @@ package com.ifmo.balda +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -9,12 +11,15 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.matcher.ViewMatchers.isClickable import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withSpinnerText import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ifmo.balda.activity.HelpScreenActivity import com.ifmo.balda.activity.MainActivity import com.ifmo.balda.activity.StatScreenActivity +import com.ifmo.balda.model.Topic +import org.hamcrest.CoreMatchers.instanceOf import org.junit.BeforeClass import org.junit.Rule import org.junit.Test @@ -25,6 +30,12 @@ class MainActivityTest { @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) + private val buttonIdToResourceId = mapOf( + R.id.easyDifficultyButton to R.string.easy, + R.id.mediumDifficultyButton to R.string.medium, + R.id.hardDifficultyButton to R.string.hard + ) + companion object { @BeforeClass @JvmStatic @@ -63,20 +74,22 @@ class MainActivityTest { @Test fun testTopicSelector() { - onView(withId(R.id.topicSelector)) + val topicSelector = onView(withId(R.id.topicSelector)) + topicSelector .check(matches(isDisplayed())) .check(matches(isClickable())) - // TODO: probably find a way to check content + + for ((idx, topic) in Topic.values().withIndex()) { + topicSelector.perform(click()) + onData(instanceOf(MainActivity.TopicSelectorItem::class.java)) + .atPosition(idx) + .perform(click()) + topicSelector.check(matches(withSpinnerText(topic.resourceId))) + } } @Test fun testDifficultyButtons() { - val buttonIdToResourceId = mapOf( - R.id.easyDifficultyButton to R.string.easy, - R.id.mediumDifficultyButton to R.string.medium, - R.id.hardDifficultyButton to R.string.hard - ) - for ((viewId, resourceId) in buttonIdToResourceId) { onView(withId(viewId)) .check(matches(isDisplayed())) @@ -104,4 +117,31 @@ class MainActivityTest { .check(matches(withTooltip(R.string.topic_help))) .perform(click()) } + + @Test + fun testTopicPersists() { + for ((idx, topic) in Topic.values().withIndex()) { + onView(withId(R.id.topicSelector)).perform(click()) + onData(instanceOf(MainActivity.TopicSelectorItem::class.java)) + .atPosition(idx) + .perform(click()) + reopenApp() + onView(withId(R.id.topicSelector)) + .check(matches(withSpinnerText(topic.resourceId))) + } + } + + @Test + fun testDifficultyPersists() { + for ((buttonId, resourceId) in buttonIdToResourceId) { + onView(withId(buttonId)).perform(click()) + reopenApp() + onView(withId(R.id.selectedDifficulty)).check(matches(withText(resourceId))) + } + } + + private fun reopenApp() { + activityRule.scenario.close() + ActivityScenario.launch(MainActivity::class.java) + } } diff --git a/app/src/androidTest/java/com/ifmo/balda/NameChoosingActivityTest.kt b/app/src/androidTest/java/com/ifmo/balda/NameChoosingActivityTest.kt index 294cc02..ef2ee57 100644 --- a/app/src/androidTest/java/com/ifmo/balda/NameChoosingActivityTest.kt +++ b/app/src/androidTest/java/com/ifmo/balda/NameChoosingActivityTest.kt @@ -1,7 +1,9 @@ package com.ifmo.balda +import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isClickable @@ -23,11 +25,11 @@ class NameChoosingActivityTest { @Test fun testSinglePlayer() { - onView(withId(R.id.startGame1PlayerButton)).perform(click()) + openSinglePlayerScreen() onView(withId(R.id.player1Name)) .check(matches(isDisplayed())) - .check(matches(withText(R.string.player))) + .check(matches(withAnyText())) onView(withId(R.id.player2Name)) .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) onView(withId(R.id.playButton)) @@ -38,17 +40,69 @@ class NameChoosingActivityTest { @Test fun testMultiplayer() { - onView(withId(R.id.startGame2PlayerButton)).perform(click()) + openMultiPlayerScreen() onView(withId(R.id.player1Name)) .check(matches(isDisplayed())) - .check(matches(withText(R.string.player1))) + .check(matches(withAnyText())) onView(withId(R.id.player2Name)) .check(matches(isDisplayed())) - .check(matches(withText(R.string.player2))) + .check(matches(withAnyText())) onView(withId(R.id.playButton)) .check(matches(isDisplayed())) .check(matches(isClickable())) .perform(click()) } + + @Test + fun testSinglePlayerNamePersists() { + val nameToCheck = "TestPersist" + + openSinglePlayerScreen() + onView(withId(R.id.player1Name)) + .perform(replaceText(nameToCheck)) + onView(withId(R.id.playButton)) + .perform(click()) + reopenApp() + openSinglePlayerScreen() + + onView(withId(R.id.player1Name)) + .check(matches(withText(nameToCheck))) + } + + @Test + fun testMultiPlayerNamePersists() { + val nameToCheck1 = "TestPersist1" + val nameToCheck2 = "TestPersist2" + + openMultiPlayerScreen() + onView(withId(R.id.player1Name)) + .perform(replaceText(nameToCheck1)) + onView(withId(R.id.player2Name)) + .perform(replaceText(nameToCheck2)) + onView(withId(R.id.playButton)) + .perform(click()) + reopenApp() + openMultiPlayerScreen() + + onView(withId(R.id.player1Name)) + .check(matches(withText(nameToCheck1))) + onView(withId(R.id.player2Name)) + .check(matches(withText(nameToCheck2))) + } + + private fun openSinglePlayerScreen() { + onView(withId(R.id.startGame1PlayerButton)) + .perform(click()) + } + + private fun openMultiPlayerScreen() { + onView(withId(R.id.startGame2PlayerButton)) + .perform(click()) + } + + private fun reopenApp() { + activityRule.scenario.close() + ActivityScenario.launch(MainActivity::class.java) + } } diff --git a/app/src/androidTest/java/com/ifmo/balda/TestUtils.kt b/app/src/androidTest/java/com/ifmo/balda/TestUtils.kt index e8d4936..73c7832 100644 --- a/app/src/androidTest/java/com/ifmo/balda/TestUtils.kt +++ b/app/src/androidTest/java/com/ifmo/balda/TestUtils.kt @@ -1,6 +1,7 @@ package com.ifmo.balda import android.view.View +import android.widget.TextView import androidx.test.platform.app.InstrumentationRegistry import org.hamcrest.Description import org.hamcrest.Matcher @@ -14,3 +15,8 @@ fun withTooltip(str: String): Matcher = object : TypeSafeMatcher() { fun withTooltip(resourceId: Int): Matcher = withTooltip( InstrumentationRegistry.getInstrumentation().targetContext.getString(resourceId) ) + +fun withAnyText(): Matcher = object : TypeSafeMatcher () { + override fun describeTo(description: Description) = Unit + override fun matchesSafely(item: View): Boolean = item is TextView && item.text.isNotEmpty() +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1c525ef..8a3a6bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:theme="@style/Theme.Adfmp1h23balda" tools:targetApi="33"> @@ -19,10 +20,22 @@ - - - - + + + + diff --git a/app/src/main/java/com/ifmo/balda/AppDatabase.kt b/app/src/main/java/com/ifmo/balda/AppDatabase.kt new file mode 100644 index 0000000..2c0783f --- /dev/null +++ b/app/src/main/java/com/ifmo/balda/AppDatabase.kt @@ -0,0 +1,46 @@ +package com.ifmo.balda + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.ifmo.balda.model.Topic +import com.ifmo.balda.model.dao.StatDao +import com.ifmo.balda.model.dao.WordDao +import com.ifmo.balda.model.entity.Stat +import com.ifmo.balda.model.entity.Word +import kotlin.streams.asSequence + +@Database(entities = [Word::class, Stat::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun wordDao(): WordDao + abstract fun statDao(): StatDao +} + +private fun initDb(ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, "balda.db") + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + ctx.resources.openRawResource(R.raw.russian_nouns) + .bufferedReader() + .lines().asSequence() + .chunked(900) // SQLITE_MAX_VARIABLE_NUMBER is 999 + .forEach { nouns -> + db.execSQL( + "insert into word (word, topic) values ${nouns.joinToString(", ") { "(?, '${Topic.COMMON.name}')" }}", + nouns.toTypedArray() + ) + } + } + + override fun onDestructiveMigration(db: SupportSQLiteDatabase) = onCreate(db) + }) + .build() + +@Volatile +private var dbInstance: AppDatabase? = null + +val Context.db: AppDatabase + get() = dbInstance ?: synchronized(applicationContext) { + dbInstance ?: initDb(applicationContext).also { dbInstance = it } + } diff --git a/app/src/main/java/com/ifmo/balda/IntentExtraNames.kt b/app/src/main/java/com/ifmo/balda/IntentExtraNames.kt index cac1f7b..cc61fbe 100644 --- a/app/src/main/java/com/ifmo/balda/IntentExtraNames.kt +++ b/app/src/main/java/com/ifmo/balda/IntentExtraNames.kt @@ -1,9 +1,7 @@ package com.ifmo.balda -class IntentExtraNames { - companion object { - const val GAME_MODE = "GAME_MODE" - const val PLAYER_1_NAME = "PLAYER_1_NAME" - const val PLAYER_2_NAME = "PLAYER_2_NAME" - } +object IntentExtraNames { + const val GAME_MODE = "GAME_MODE" + const val PLAYER_1_NAME = "PLAYER_1_NAME" + const val PLAYER_2_NAME = "PLAYER_2_NAME" } diff --git a/app/src/main/java/com/ifmo/balda/PreferencesKeys.kt b/app/src/main/java/com/ifmo/balda/PreferencesKeys.kt new file mode 100644 index 0000000..16cfd48 --- /dev/null +++ b/app/src/main/java/com/ifmo/balda/PreferencesKeys.kt @@ -0,0 +1,10 @@ +package com.ifmo.balda + +object PreferencesKeys { + const val preferencesFileKey = "prefFile" + const val difficultyKey = "difficulty" + const val topicKey = "topic" + const val singlePlayerNameKey = "singlePlayerName" + const val multiPlayer1PlayerNameKey = "multiPlayer1PlayerName" + const val multiPlayer2PlayerNameKey = "multiPlayer2PlayerName" +} diff --git a/app/src/main/java/com/ifmo/balda/activity/ChooseNameActivity.kt b/app/src/main/java/com/ifmo/balda/activity/ChooseNameActivity.kt index 9b4703c..b0bc4ab 100644 --- a/app/src/main/java/com/ifmo/balda/activity/ChooseNameActivity.kt +++ b/app/src/main/java/com/ifmo/balda/activity/ChooseNameActivity.kt @@ -1,6 +1,8 @@ package com.ifmo.balda.activity -import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.view.View import android.widget.Button @@ -8,13 +10,19 @@ import android.widget.EditText import android.widget.ImageButton import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NavUtils -import com.ifmo.balda.GameMode import com.ifmo.balda.IntentExtraNames +import com.ifmo.balda.PreferencesKeys import com.ifmo.balda.R -import com.ifmo.balda.setOnClickActivity +import com.ifmo.balda.model.GameMode class ChooseNameActivity : AppCompatActivity() { - @SuppressLint("CutPasteId") + // WARNING: safe to use ONLY in [onCreate] and after that + private val gameMode + get() = GameMode.valueOf( + intent.getStringExtra(IntentExtraNames.GAME_MODE) + ?: error("Missing required extra property ${IntentExtraNames.GAME_MODE}") + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_choose_name_screen) @@ -23,43 +31,70 @@ class ChooseNameActivity : AppCompatActivity() { NavUtils.navigateUpFromSameTask(this) } - val gameMode = GameMode.valueOf( - intent.getStringExtra(IntentExtraNames.GAME_MODE) - ?: error("Missing required extra property ${IntentExtraNames.GAME_MODE}") - ) + val prefs = getSharedPreferences(PreferencesKeys.preferencesFileKey, Context.MODE_PRIVATE) + val player1NameEdit = findViewById(R.id.player1Name) + val player2NameEdit = findViewById(R.id.player2Name) when (gameMode) { GameMode.SINGLE_PLAYER -> { - findViewById(R.id.player2Name).visibility = View.GONE + player2NameEdit.visibility = View.GONE - val nameEdit = findViewById(R.id.player1Name) - nameEdit.setHint(R.string.playerName) - nameEdit.setText(R.string.player) - nameEdit.setSelection(nameEdit.text.length) + val playerName = prefs.getString(PreferencesKeys.singlePlayerNameKey, resources.getString(R.string.player))!! + player1NameEdit.setHint(R.string.playerName) + player1NameEdit.setText(playerName) + player1NameEdit.setSelection(player1NameEdit.text.length) } GameMode.MULTIPLAYER -> { - val name1Edit = findViewById(R.id.player1Name) - val name2Edit = findViewById(R.id.player2Name) + val player1Name = prefs.getString( + PreferencesKeys.multiPlayer1PlayerNameKey, + resources.getString(R.string.player1) + )!! + val player2Name = prefs.getString( + PreferencesKeys.multiPlayer2PlayerNameKey, + resources.getString(R.string.player2) + )!! - name1Edit.setHint(R.string.player1Name) - name1Edit.setText(R.string.player1) - name1Edit.setSelection(name1Edit.text.length) - name2Edit.setHint(R.string.player2Name) - name2Edit.setText(R.string.player2) - name2Edit.setSelection(name2Edit.text.length) + player1NameEdit.setHint(R.string.player1Name) + player1NameEdit.setText(player1Name) + player1NameEdit.setSelection(player1NameEdit.text.length) + player2NameEdit.setHint(R.string.player2Name) + player2NameEdit.setText(player2Name) + player2NameEdit.setSelection(player2NameEdit.text.length) } } - findViewById