diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6cfa2f9..0836ff1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,6 +64,8 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + testImplementation("io.mockk:mockk:1.12.0") + // --- kotlinx extras implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1") diff --git a/app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientTest.kt b/app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImplTest.kt similarity index 91% rename from app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientTest.kt rename to app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImplTest.kt index d2bdd64..4bbdbf3 100644 --- a/app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientTest.kt +++ b/app/src/androidTest/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImplTest.kt @@ -10,14 +10,14 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test -class HLTBClientTest { +class HLTBClientImplTest { - private lateinit var client: HLTBClient + private lateinit var client: HLTBClientImpl @Before fun setUp() { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - client = HLTBClient(appContext) + client = HLTBClientImpl(appContext) } @Test diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt b/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt index 965f38f..ea28b38 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt @@ -14,14 +14,9 @@ import androidx.core.content.PermissionChecker import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController -import be.kuleuven.howlongtobeat.cartridges.CartridgeFinderViaDuckDuckGo -import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository -import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi -import be.kuleuven.howlongtobeat.cartridges.findFirstCartridgeForRepos import be.kuleuven.howlongtobeat.databinding.FragmentLoadingBinding -import be.kuleuven.howlongtobeat.google.GoogleVisionClient -import be.kuleuven.howlongtobeat.hltb.HLTBClient import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult +import be.kuleuven.howlongtobeat.model.GameFinder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -29,10 +24,6 @@ import java.io.File class LoadingFragment : Fragment(R.layout.fragment_loading) { - private lateinit var hltbClient: HLTBClient - private lateinit var cartRepos: List - private lateinit var imageRecognizer: ImageRecognizer - private lateinit var cameraPermissionActivityResult: ActivityResultLauncher private lateinit var cameraActivityResult: ActivityResultLauncher private lateinit var main: MainActivity @@ -48,14 +39,6 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { binding = FragmentLoadingBinding.inflate(layoutInflater) main = activity as MainActivity - // If we fail to find info in the first repo, it falls back to the second one: a (scraped) DuckDuckGo search. - cartRepos = listOf( - CartridgesRepositoryGekkioFi.fromAsset(main.applicationContext), - CartridgeFinderViaDuckDuckGo(main.applicationContext) - ) - imageRecognizer = GoogleVisionClient() - hltbClient = HLTBClient(main.applicationContext) - cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken) cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied) binding.btnRetryAfterLoading.setOnClickListener { @@ -105,19 +88,11 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { // Uncomment this line if you want to stub out camera pictures // picToAnalyze = BitmapFactory.decodeResource(resources, R.drawable.sml2) - progress("Recognizing game cart from picture...") - val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze) - ?: throw UnableToFindGameException("No cart code in your pic found") + val hltbResults = GameFinder.default(main.applicationContext).findGameBasedOnCameraSnap(picToAnalyze) { + progress(it) + } - progress("Found cart code $cartCode\nLooking in DBs for matching game...") - val foundCart = findFirstCartridgeForRepos(cartCode, cartRepos) - ?: throw UnableToFindGameException("$cartCode is an unknown game cart.") - - progress("Valid cart code: $cartCode\n Looking in HLTB for ${foundCart.title}...") - val hltbResults = hltbClient.find(foundCart) - ?: throw UnableToFindGameException("HLTB does not know ${foundCart.title}") - - Snackbar.make(requireView(), "Found ${hltbResults.size} game(s) for cart ${foundCart.code}", Snackbar.LENGTH_LONG).show() + Snackbar.make(requireView(), "Found ${hltbResults.size} game(s)", Snackbar.LENGTH_LONG).show() val bundle = bundleOf( HowLongToBeatResult.RESULT to hltbResults, HowLongToBeatResult.SNAPSHOT_URI to snapshot.toString() diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/cartridges/DuckDuckGoResultParser.kt b/app/src/main/java/be/kuleuven/howlongtobeat/cartridges/DuckDuckGoResultParser.kt index 8bda028..d1a02c3 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/cartridges/DuckDuckGoResultParser.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/cartridges/DuckDuckGoResultParser.kt @@ -8,11 +8,14 @@ object DuckDuckGoResultParser { ".", "-", "/", + "\n", ",", "!", "Get information and compare prices of", "for Game Boy", "Release Information", + "Release Dates", + "Mobygames", "Nintendo", "Game Boy Advance", "Game Boy color", diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt index ff28cf3..bebe74f 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt @@ -1,54 +1,7 @@ package be.kuleuven.howlongtobeat.hltb -import android.content.Context import be.kuleuven.howlongtobeat.cartridges.Cartridge -import com.android.volley.Response -import com.android.volley.toolbox.StringRequest -import com.android.volley.toolbox.Volley -import kotlin.coroutines.suspendCoroutine -class HLTBClient(val context: Context) { - - companion object { - const val DOMAIN = "https://howlongtobeat.com" - } - - // Inspired by https://www.npmjs.com/package/howlongtobeat - // The API is abysmal, but hey, it works... - class HLTBRequest(val query: String, responseListener: Response.Listener) : - StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener, - Response.ErrorListener { - println("Something went wrong: ${it.message}") - }) { - override fun getBodyContentType(): String { - return "application/x-www-form-urlencoded" - } - - override fun getParams(): MutableMap { - return hashMapOf( - "queryString" to query, - "t" to "games", - "sorthead" to "popular", - "sortd" to "0", - "plat" to "", - "length_type" to "main", - "length_min" to "", - "length_max" to "", - "detail" to "0" - ) - } - - override fun getHeaders(): MutableMap { - return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0") - } - } - - suspend fun find(cart: Cartridge): List? = suspendCoroutine { cont -> - val queue = Volley.newRequestQueue(context) - val req = HLTBRequest(cart.title) { - val hltbResults = HowLongToBeatResultParser.parse(it, cart) - cont.resumeWith(Result.success(hltbResults)) - } - queue.add(req) - } -} \ No newline at end of file +interface HLTBClient { + suspend fun find(cart: Cartridge): List? +} diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImpl.kt b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImpl.kt new file mode 100644 index 0000000..ce6402d --- /dev/null +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClientImpl.kt @@ -0,0 +1,54 @@ +package be.kuleuven.howlongtobeat.hltb + +import android.content.Context +import be.kuleuven.howlongtobeat.cartridges.Cartridge +import com.android.volley.Response +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import kotlin.coroutines.suspendCoroutine + +class HLTBClientImpl(val context: Context) : HLTBClient { + + companion object { + const val DOMAIN = "https://howlongtobeat.com" + } + + // Inspired by https://www.npmjs.com/package/howlongtobeat + // The API is abysmal, but hey, it works... + class HLTBRequest(val query: String, responseListener: Response.Listener) : + StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener, + Response.ErrorListener { + println("Something went wrong: ${it.message}") + }) { + override fun getBodyContentType(): String { + return "application/x-www-form-urlencoded" + } + + override fun getParams(): MutableMap { + return hashMapOf( + "queryString" to query, + "t" to "games", + "sorthead" to "popular", + "sortd" to "0", + "plat" to "", + "length_type" to "main", + "length_min" to "", + "length_max" to "", + "detail" to "0" + ) + } + + override fun getHeaders(): MutableMap { + return hashMapOf("User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0") + } + } + + override suspend fun find(cart: Cartridge): List? = suspendCoroutine { cont -> + val queue = Volley.newRequestQueue(context) + val req = HLTBRequest(cart.title) { + val hltbResults = HowLongToBeatResultParser.parse(it, cart) + cont.resumeWith(Result.success(hltbResults)) + } + queue.add(req) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt index 69f8259..f292477 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt @@ -10,7 +10,7 @@ data class HowLongToBeatResult(val title: String, val cartCode: String, val howl const val SNAPSHOT_URI = "SnapshotUri" } - fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClient.DOMAIN) + fun hasBoxart(): Boolean = boxartUrl.startsWith(HLTBClientImpl.DOMAIN) fun boxartUrl(): URL = URL(boxartUrl) override fun toString(): String = "$title ($howlong hrs)" } \ No newline at end of file diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt index b5d8e75..ef32733 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt @@ -30,7 +30,7 @@ object HowLongToBeatResultParser { if(row - 3 >= 0) { val matchedBoxArt = boxArtMatcher.find(rows[row - 3]) if(matchedBoxArt != null) { - return HLTBClient.DOMAIN + matchedBoxArt.groupValues[1] + return HLTBClientImpl.DOMAIN + matchedBoxArt.groupValues[1] } } return "" diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/model/GameFinder.kt b/app/src/main/java/be/kuleuven/howlongtobeat/model/GameFinder.kt new file mode 100644 index 0000000..70c28f3 --- /dev/null +++ b/app/src/main/java/be/kuleuven/howlongtobeat/model/GameFinder.kt @@ -0,0 +1,51 @@ +package be.kuleuven.howlongtobeat.model + +import android.content.Context +import android.graphics.Bitmap +import be.kuleuven.howlongtobeat.ImageRecognizer +import be.kuleuven.howlongtobeat.UnableToFindGameException +import be.kuleuven.howlongtobeat.cartridges.CartridgeFinderViaDuckDuckGo +import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository +import be.kuleuven.howlongtobeat.cartridges.CartridgesRepositoryGekkioFi +import be.kuleuven.howlongtobeat.cartridges.findFirstCartridgeForRepos +import be.kuleuven.howlongtobeat.google.GoogleVisionClient +import be.kuleuven.howlongtobeat.hltb.HLTBClient +import be.kuleuven.howlongtobeat.hltb.HLTBClientImpl +import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult + +/** + * This class separates Android-specific logic with our own domain logic. + * WHY? Because of GameFinderTest and mockability! + */ +class GameFinder( + private val hltbClient: HLTBClient, + private val cartRepos: List, + private val imageRecognizer: ImageRecognizer) { + + companion object { + fun default(context: Context): GameFinder { + // If we fail to find info in the first repo, it falls back to the second one: a (scraped) DuckDuckGo search. + val cartRepos = listOf( + CartridgesRepositoryGekkioFi.fromAsset(context), + CartridgeFinderViaDuckDuckGo(context) + ) + return GameFinder(HLTBClientImpl(context), cartRepos, GoogleVisionClient()) + } + } + + suspend fun findGameBasedOnCameraSnap(picToAnalyze: Bitmap, progress: (msg: String) -> Unit): List { + progress("Recognizing game cart from picture...") + val cartCode = imageRecognizer.recognizeCartCode(picToAnalyze) + ?: throw UnableToFindGameException("No cart code in your pic found") + + progress("Found cart code $cartCode\nLooking in DBs for matching game...") + val foundCart = findFirstCartridgeForRepos(cartCode, cartRepos) + ?: throw UnableToFindGameException("$cartCode is an unknown game cart.") + + progress("Valid cart code: $cartCode\n Looking in HLTB for ${foundCart.title}...") + val hltbResults = hltbClient.find(foundCart) + ?: throw UnableToFindGameException("HLTB does not know ${foundCart.title}") + + return hltbResults + } +} diff --git a/app/src/test/java/be/kuleuven/howlongtobeat/model/GameFinderTest.kt b/app/src/test/java/be/kuleuven/howlongtobeat/model/GameFinderTest.kt new file mode 100644 index 0000000..b06a30d --- /dev/null +++ b/app/src/test/java/be/kuleuven/howlongtobeat/model/GameFinderTest.kt @@ -0,0 +1,120 @@ +package be.kuleuven.howlongtobeat.model + +import be.kuleuven.howlongtobeat.ImageRecognizer +import be.kuleuven.howlongtobeat.UnableToFindGameException +import be.kuleuven.howlongtobeat.cartridges.Cartridge +import be.kuleuven.howlongtobeat.cartridges.CartridgesRepository +import be.kuleuven.howlongtobeat.hltb.HLTBClient +import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.util.concurrent.Executors + +class GameFinderTest { + + private lateinit var finder: GameFinder + @MockK + private lateinit var hltbClient: HLTBClient + @MockK + private lateinit var imageRecognizer: ImageRecognizer + @MockK + private lateinit var cartridgesRepository: CartridgesRepository + + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + @Before fun setUp() { + MockKAnnotations.init(this) + finder = GameFinder(hltbClient, listOf(cartridgesRepository), imageRecognizer) + Dispatchers.setMain(dispatcher) + } + + @After fun tearDown() { + Dispatchers.resetMain() + dispatcher.close() + } + + @Test fun findGameBasedOnCameraSnap_UnrecognizableAccordingToGoogleVision_fails() = runBlocking { + coEvery { imageRecognizer.recognizeCartCode(any()) } returns null + + try { + finder.findGameBasedOnCameraSnap(mockk()) { + } + Assert.fail("Expected exception to occur") + } catch(expected: UnableToFindGameException) { + Assert.assertEquals("No cart code in your pic found", expected.message) + } + } + + @Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToDBs_fails() = runBlocking { + coEvery { imageRecognizer.recognizeCartCode(any()) } returns "DMG-MQ-EUR" + coEvery { cartridgesRepository.find("DMG-MQ-EUR") } returns null + + try { + finder.findGameBasedOnCameraSnap(mockk()) { + } + Assert.fail("Expected exception to occur") + } catch(expected: UnableToFindGameException) { + Assert.assertEquals("DMG-MQ-EUR is an unknown game cart.", expected.message) + } + } + + @Test fun findGameBasedOnCameraSnap_UnknownCartridgeAccordingToFirstDBButSecondFindsIt_returnsHltbResults() = runBlocking { + val secondCartridgeDb = mockk() + val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR") + + coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code + coEvery { cartridgesRepository.find(cart.code) } returns null + coEvery { secondCartridgeDb.find(cart.code) } returns cart + coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5)) + finder = GameFinder(hltbClient, listOf(cartridgesRepository, secondCartridgeDb), imageRecognizer) + + val foundGames = finder.findGameBasedOnCameraSnap(mockk()) { + } + + assertEquals(1, foundGames.size) + assertEquals(34.5, foundGames.single().howlong) + } + + @Test fun findGameBasedOnCameraSnap_UnknownGameAccordingToHLTB_fails() = runBlocking { + val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR") + + coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code + coEvery { cartridgesRepository.find(cart.code) } returns cart + coEvery { hltbClient.find(cart) } returns null + + try { + finder.findGameBasedOnCameraSnap(mockk()) { + } + Assert.fail("Expected exception to occur") + } catch(expected: UnableToFindGameException) { + Assert.assertEquals("HLTB does not know Mario Land 356", expected.message) + } + } + + @Test fun findGameBasedOnCameraSnap_validGame_returnsHltbResults() = runBlocking { + val cart = Cartridge("type", "Mario Land 356", "DMG-MQ-EUR") + + coEvery { imageRecognizer.recognizeCartCode(any()) } returns cart.code + coEvery { cartridgesRepository.find(cart.code) } returns cart + coEvery { hltbClient.find(cart) } returns listOf(HowLongToBeatResult(cart.title, cart.code, 34.5)) + + val foundGames = finder.findGameBasedOnCameraSnap(mockk()) { + } + + assertEquals(1, foundGames.size) + assertEquals(34.5, foundGames.single().howlong) + + } +} \ No newline at end of file