diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4309c5..9f094ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,7 @@ android { dependencies { // --- defaults implementation("androidx.core:core-ktx:1.6.0") + implementation("androidx.activity:activity-ktx:1.3.1") implementation("androidx.appcompat:appcompat:1.3.1") implementation("com.google.android.material:material:1.4.0") implementation("androidx.constraintlayout:constraintlayout:2.1.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 995be0e..7f4662d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ package="be.kuleuven.howlongtobeat"> - + + + + + \ No newline at end of file diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/AndroidExtensions.kt b/app/src/main/java/be/kuleuven/howlongtobeat/AndroidExtensions.kt new file mode 100644 index 0000000..77aaac5 --- /dev/null +++ b/app/src/main/java/be/kuleuven/howlongtobeat/AndroidExtensions.kt @@ -0,0 +1,18 @@ +package be.kuleuven.howlongtobeat + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import kotlin.math.roundToInt + +fun Uri.toBitmap(activity: Activity): Bitmap { + return BitmapFactory.decodeStream(activity.contentResolver.openInputStream(this)) +} + +fun Bitmap.scaleToWidth(width: Int): Bitmap { + val aspectRatio = this.width.toFloat() / this.height.toFloat() + val height = (width / aspectRatio).roundToInt() + + return Bitmap.createScaledBitmap(this, width, height, false) +} \ No newline at end of file diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/GameListFragment.kt b/app/src/main/java/be/kuleuven/howlongtobeat/GameListFragment.kt index e46eef6..46b9bb6 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/GameListFragment.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/GameListFragment.kt @@ -35,7 +35,9 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) { binding.rvGameList.adapter = adapter binding.rvGameList.layoutManager = LinearLayoutManager(this.context) - binding.btnAddTodo.setOnClickListener(this::addNewTotoItem) + binding.btnAddTodo.setOnClickListener { + findNavController().navigate(R.id.action_gameListFragment_to_loadingFragment) + } return binding.root } @@ -46,10 +48,6 @@ class GameListFragment : Fragment(R.layout.fragment_gamelist) { } } - private fun addNewTotoItem(it: View) { - findNavController().navigate(R.id.action_gameListFragment_to_loadingFragment) - } - /* fun onHltbGamesRetrieved(games: List) { gameList.clear() diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/HltbResultsAdapter.kt b/app/src/main/java/be/kuleuven/howlongtobeat/HltbResultsAdapter.kt index d9fb17b..c2bd817 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/HltbResultsAdapter.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/HltbResultsAdapter.kt @@ -1,11 +1,19 @@ package be.kuleuven.howlongtobeat +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + class HltbResultsAdapter(private val items: List) : RecyclerView.Adapter() { @@ -17,10 +25,25 @@ class HltbResultsAdapter(private val items: List) : Recycle } override fun onBindViewHolder(holder: HltbResultsViewHolder, position: Int) { - val currentResult = items[position] + val itm = items[position] + holder.itemView.apply { - val txtHltbItemResult = findViewById(R.id.txtHltbItemResult) - txtHltbItemResult.text = currentResult.title + findViewById(R.id.txtHltbItemResult).text = itm.toString() + val boxArtView = findViewById(R.id.imgHltbItemResult) + + if(itm.hasBoxart()) { + MainScope().launch{ + var art: Bitmap? = null + withContext(Dispatchers.IO) { + art = BitmapFactory.decodeStream(itm.boxartUrl().openConnection().getInputStream()) + } + withContext(Dispatchers.Main) { + boxArtView.setImageBitmap(art) + } + } + } else { + boxArtView.visibility = View.INVISIBLE + } } } diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt b/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt index 741e99c..3a20a2a 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/LoadingFragment.kt @@ -1,17 +1,18 @@ package be.kuleuven.howlongtobeat import android.Manifest -import android.content.pm.PackageManager import android.graphics.Bitmap -import android.graphics.BitmapFactory +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.content.PermissionChecker import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import be.kuleuven.howlongtobeat.cartridges.Cartridge @@ -23,6 +24,8 @@ import be.kuleuven.howlongtobeat.hltb.HowLongToBeatResult import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import java.io.File + class LoadingFragment : Fragment(R.layout.fragment_loading) { @@ -31,10 +34,12 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { private lateinit var visionClient: GoogleVisionClient private lateinit var cameraPermissionActivityResult: ActivityResultLauncher - private lateinit var cameraActivityResult: ActivityResultLauncher + private lateinit var cameraActivityResult: ActivityResultLauncher private lateinit var main: MainActivity private lateinit var binding: FragmentLoadingBinding + private var snapshot: Uri? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -47,24 +52,33 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { visionClient = GoogleVisionClient() hltbClient = HLTBClient(main.applicationContext) - cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicturePreview(), this::cameraSnapTaken) + cameraActivityResult = registerForActivityResult(ActivityResultContracts.TakePicture(), this::cameraSnapTaken) cameraPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission(), this::cameraPermissionAcceptedOrDenied) + binding.btnRetryAfterLoading.setOnClickListener { + tryToMakeCameraSnap() + } tryToMakeCameraSnap() return binding.root } - private fun cameraSnapTaken(pic: Bitmap) { + private fun cameraSnapTaken(succeeded: Boolean) { + if(!succeeded || snapshot == null) { + errorInProgress("Photo could not be saved, try again?") + return + } + + progress("Scaling image for upload...") + val bitmap = snapshot!!.toBitmap(main).scaleToWidth(1600) + MainScope().launch{ - findGameBasedOnCameraSnap(pic) + findGameBasedOnCameraSnap(bitmap) } } private suspend fun findGameBasedOnCameraSnap(pic: Bitmap) { progress("Unleashing Google Vision on the pic...") - // TODO remove in future - val dummypic = BitmapFactory.decodeResource(resources, R.drawable.supermarioland2) - val cartCode = visionClient.findCartCodeViaGoogleVision(dummypic) + val cartCode = visionClient.findCartCodeViaGoogleVision(pic) if (cartCode == null) { errorInProgress("Unable to find a code in your pic. Retry?") @@ -93,35 +107,46 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { if(succeeded) { makeCameraSnap() } else { - progress("Camera permission required!") + errorInProgress("Camera permission required!") } } private fun tryToMakeCameraSnap() { + binding.btnRetryAfterLoading.hide() progress("Making snapshot with camera...") - if(ContextCompat.checkSelfPermission(main, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + + if(PermissionChecker.checkSelfPermission(main, Manifest.permission.CAMERA) != PermissionChecker.PERMISSION_GRANTED) { cameraPermissionActivityResult.launch(Manifest.permission.CAMERA) + } else { + makeCameraSnap() } - makeCameraSnap() + } + + private fun createNewTempCameraFile() { + val tempFile = File.createTempFile("hltbCameraSnap", ".png", main.cacheDir).apply { + createNewFile() + deleteOnExit() + } + snapshot = FileProvider.getUriForFile(main.applicationContext, "${BuildConfig.APPLICATION_ID}.provider", tempFile) } private fun makeCameraSnap() { - cameraActivityResult.launch(null) + createNewTempCameraFile() + cameraActivityResult.launch(snapshot) } private fun progress(msg: String) { - if(!binding.indeterminateBar.isAnimating) { - binding.indeterminateBar.animate() + if(!binding.indeterminateBar.isVisible) { + binding.indeterminateBar.visibility = View.VISIBLE } binding.txtLoading.text = msg } private fun errorInProgress(msg: String) { progress(msg) - binding.indeterminateBar.clearAnimation() + binding.indeterminateBar.visibility = View.GONE Snackbar.make(requireView(), msg, Snackbar.LENGTH_LONG).show() - // todo show retry button or something? + binding.btnRetryAfterLoading.show() } - } \ No newline at end of file diff --git a/app/src/main/java/be/kuleuven/howlongtobeat/google/GoogleVisionClient.kt b/app/src/main/java/be/kuleuven/howlongtobeat/google/GoogleVisionClient.kt index 3bf37d6..cbe61e3 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/google/GoogleVisionClient.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/google/GoogleVisionClient.kt @@ -19,6 +19,7 @@ class GoogleVisionClient { // TODO encrypt and store externally: https://cloud.google.com/docs/authentication/api-keys?hl=en&visit_id=637642790375688006-1838986332&rd=1 private val vision = Vision.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), null) .setVisionRequestInitializer(VisionRequestInitializer("AIzaSyCaMjQQOY7508y95riDhr25fsrqe3m2JW0")) + .setApplicationName("How Long To Beat") .build() suspend fun findCartCodeViaGoogleVision(cameraSnap: Bitmap): String? { @@ -39,7 +40,9 @@ class GoogleVisionClient { } response = vision.images().annotate(batch).execute() } - if(response.responses.isEmpty()) { + if(response.responses.isEmpty() + || response.responses.get(0).textAnnotations == null + || response.responses.get(0).textAnnotations.isEmpty()) { return null } @@ -48,4 +51,4 @@ class GoogleVisionClient { }.firstOrNull() return gbId?.description ?: null } -} \ No newline at end of file +} 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 e9544f2..def7b73 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HLTBClient.kt @@ -7,10 +7,14 @@ import com.android.volley.toolbox.Volley 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, "https://howlongtobeat.com/search_results.php?page=1", responseListener, + StringRequest(Method.POST, "$DOMAIN/search_results.php?page=1", responseListener, Response.ErrorListener { println("Something went wrong: ${it.message}") }) { 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 95bce86..475a2bc 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResult.kt @@ -1,11 +1,16 @@ package be.kuleuven.howlongtobeat.hltb import kotlinx.serialization.Serializable +import java.net.URL @Serializable -data class HowLongToBeatResult(val title: String, val howlong: Double, val finished: Boolean = false) : java.io.Serializable { +data class HowLongToBeatResult(val title: String, val howlong: Double, val boxart: String = "") : java.io.Serializable { companion object { const val RESULT = "HowLongToBeatResult" const val CODE = "CartCode" } + + fun hasBoxart(): Boolean = boxart.startsWith(HLTBClient.DOMAIN) + fun boxartUrl(): URL = URL(boxart) + 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 2011514..316c071 100644 --- a/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt +++ b/app/src/main/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParser.kt @@ -4,6 +4,7 @@ object HowLongToBeatResultParser { private val titleMatcher = """(.+) Hours""".toRegex() + private val boxArtMatcher = """.+ { val result = arrayListOf() @@ -13,14 +14,26 @@ object HowLongToBeatResultParser { if(matched != null) { val (title) = matched.destructured val hour = parseHoursFromRow(i, rows) + val boxart = parseBoxArtFromRow(i, rows) - result.add(HowLongToBeatResult(title, hour)) + result.add(HowLongToBeatResult(title, hour, boxart)) } } return result } + private fun parseBoxArtFromRow(row: Int, rows: List): String { + // three rows up, there should be an image tag with the box art + if(row - 3 >= 0) { + val matchedBoxArt = boxArtMatcher.find(rows[row - 3]) + if(matchedBoxArt != null) { + return HLTBClient.DOMAIN + matchedBoxArt.groupValues[1] + } + } + return "" + } + private fun parseHoursFromRow(row: Int, rows: List): Double { var hour = -1.0 // two rows down, there should be a
6½ Hours
diff --git a/app/src/main/res/layout/fragment_loading.xml b/app/src/main/res/layout/fragment_loading.xml index 6f4f817..fcbe915 100644 --- a/app/src/main/res/layout/fragment_loading.xml +++ b/app/src/main/res/layout/fragment_loading.xml @@ -12,7 +12,8 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + android:indeterminate="true" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_hltbresult.xml b/app/src/main/res/layout/item_hltbresult.xml index c7232af..59e1847 100644 --- a/app/src/main/res/layout/item_hltbresult.xml +++ b/app/src/main/res/layout/item_hltbresult.xml @@ -1,5 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..71c4032 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/test/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParserTest.kt b/app/src/test/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParserTest.kt index 7826127..4bbd06f 100644 --- a/app/src/test/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParserTest.kt +++ b/app/src/test/java/be/kuleuven/howlongtobeat/hltb/HowLongToBeatResultParserTest.kt @@ -73,6 +73,8 @@ I/System.out:
Ma assertEquals(3, result.size) assertEquals("Super Mario Land", smland.title) assertEquals("Super Mario 3D Land", sm3dland.title) + assertEquals("https://howlongtobeat.com/games/250px-Supermariolandboxart.jpg", smland.boxart) + assertEquals("https://howlongtobeat.com/games/250px-Super-Mario-3D-Land-Logo.jpg", sm3dland.boxart) assertEquals(1.0, smland.howlong, 0.0) assertEquals(6.5, sm3dland.howlong, 0.0) }