【Android】Android / Kotlin で画像検索を実装する

■ はじめに

https://dk521123.hatenablog.com/entry/2020/07/20/232009

の続き。

Android で 英単語帳を作っているのだが
以下のサイトなどにあるように

https://atsueigo.com/vocabulary_google_image/
https://zinsoku.com/english-google-images/

英単語の暗記を一助のために、英単語から画像検索(※)を行いたい。
※ 今回は、Googleの画像検索を使う。ただ、Yahooでも別の検索でも基本同じ

比較的すぐに実装できるかなっと思ったが、意外とはまって
また、以下の「画像検索機能により学べた事項」のように
色々と学べることが多かった。

画像検索機能により学べた事項

1)Google画像検索の仕様・作り(著作権Freeなど)
2) 画像検索のHTML取得・解析処理(ちょっとしたスクレイピング)... Jsopライブラリ
3)2)を行う際の非同期処理 ... Corutinesライブラリ
4)非同期時のグルグル表示 ... ProgressBar
5)画像URLから画像を表示する際の処理 ... Picassoライブラリ
etc...

目次

【1】画像検索機能
 1)Google画像検索の著作権について
 2)対象画像の取得方法について
【2】使用ライブラリ
【3】サンプル

【1】画像検索機能

画像検索機能には、以下を参考(ベース)に考えたが

https://qiita.com/ChanJun/items/e1b5a16a19b0c681ff90

以下の点で注意が必要

1)画像の著作権について
2)対象画像の取得方法について

1)Google画像検索の著作権について

https://ferret-plus.com/337

で、クリエイティブ・コモンズ(Creative Commons、略称: CC)ライセンス
っていう著作者が自ら著作物の再利用を許可する設定ができるらしいので、
やってみたら、以下のようなフォーマットになった。

Google画像検索のURLフォーマット

// ${keyword} = キーワード、hl=ja : 日本語サイト
val url = "https://www.google.com/search?as_st=y&tbm=isch&hl=ja&as_q=${keyword}" +
         "&as_epq=&as_oq=&as_eq=&imgsz=&imgar=&imgc=&imgcolor=&imgtype=&cr=&" +
         "as_sitesearch=&safe=images&as_filetype=&tbs=sur%3Acl"

2)対象画像の取得方法について

https://qiita.com/ChanJun/items/e1b5a16a19b0c681ff90

でやっている正規表現のまま、画像のURLを取得すると
以下の2点で意図したデータを取得できなかったので
そのデータは事前に省く必要がある。
~~~~~~~~~
A)一発目は、Google検索自体の画像
B)http/https以外の画像データ
~~~~~~~~~

※
言葉だけではイメージ付きづらいと思うので、
PCのブラウザで画像検索をし、HTMLを表示し、
「<img 」で検索してみると理解しやすいかも。

A)一発目は、Google検索自体の画像

* Google検索のロゴが表示されるので、
 そこは欲しい画像ではないので省く
~~~~~~~~~
<img src="https://www.google.com/logos/...
~~~~~~~~~

B)http/https以外の画像データ

* img src="data:image/gif;..." のように画像URLではないデータが
 含まれるので、これも除外する
~~~~~~~~~
<img src="data:image/gif;base64,R0lG ..."
~~~~~~~~~

【2】使用ライブラリ

1)Google画像検索からの画像URL取得するためのライブラリ
 A)JSOUP ... Java製の HTML 解析・編集ライブラリ
 B)Coroutine(コルーチン) ... Kotlin 標準の非同期用ライブラリ
 ⇒ B)を使わないと、A)内でエラーにあるので使用。

2)画像URLから画像を表示する際のライブラリ
 C)Picasso(ピカソ) ... Android用画像ライブラリ

https://androidpedia.net/ja/tutorial/2172/---

# この辺のライブラリは、機会があれば別途掘り下げたい

【3】サンプル

* 以下の Github にアップしてあるので、そちらを参照。
 => ここでは、一部をコメント付きで、紹介。

https://github.com/dk521123/EnglishCards
https://github.com/dk521123/EnglishCards/tree/develop/app/src/main/java/com/dk/englishcards/edit
build.gradle

dependencies {
    // JSOUP
    implementation 'org.jsoup:jsoup:1.13.1'
    // Coroutine(コルーチン)
    def coroutines_version = '1.3.9'
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
    // Picasso(ピカソ)
    implementation 'com.squareup.picasso:picasso:2.71828'
    // Recyclerview
   implementation 'androidx.recyclerview:recyclerview:1.1.0'
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.dk.englishcards" >
    <!-- 追加(インターネット接続するために必要) -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <application・・・略・・・
</manifest>

ShowImagesActivity.kt
https://github.com/dk521123/EnglishCards/blob/develop/app/src/main/java/com/dk/englishcards/edit/ShowImagesActivity.kt

import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.ProgressBar
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.dk.englishcards.R
import com.dk.englishcards.cards.EnglishCard
import com.dk.englishcards.commons.BaseSubPageActivity
import kotlinx.android.synthetic.main.activity_show_images.*
import kotlinx.coroutines.*
import org.jsoup.Jsoup
import java.util.regex.Pattern

class ShowImagesActivity : BaseSubPageActivity() {
    private lateinit var englishWord: String
    private val mainHandler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_show_images)

        this.englishWord = intent.getStringExtra(EnglishCard.ENGLISH_FIELD)

        targetEnglishWordTextView.text = this.englishWord
        this.showImageList(false)
        doParallelTaskAsync(this, this.englishWord)
    }

    private fun showImageList(isShown: Boolean) {
        if (isShown) {
            imagesProgressbar.visibility = ProgressBar.INVISIBLE
            imagesRecyclerView.visibility = RecyclerView.VISIBLE
        } else {
            imagesProgressbar.visibility = ProgressBar.VISIBLE
            imagesRecyclerView.visibility = RecyclerView.INVISIBLE
        }
    }

    private fun doParallelTaskAsync(context: Context, englishWord: String) = GlobalScope.async {
        val task = RetrieveImagesTask()
        val imageUrlList = task.getImageUrls(englishWord)
        if (imageUrlList.isEmpty()) {
            Toast.makeText(
                applicationContext,
                "Failed to get images...",
                Toast.LENGTH_LONG
            ).show()
        } else {
            try {
                val imagesGridAdapter = ImageListRecyclerViewAdapter(imageUrlList)
                mainHandler.post(Runnable {
                    showImageList(true)
                    imagesRecyclerView.layoutManager = LinearLayoutManager(context)
                    imagesRecyclerView.adapter = imagesGridAdapter
                    imagesRecyclerView.setHasFixedSize(true)
                })
            } catch (ex: java.lang.Exception) {
                ex.printStackTrace()
                Toast.makeText(
                    applicationContext,
                    "Failed to set images adapter...",
                    Toast.LENGTH_LONG
                ).show()
            }
        }
    }

    inner class RetrieveImagesTask {
        // httpが記載されてある画像のみを取得するための定数
        private val TOKEN_OF_HTTP_PROTOCAL = "http"

        fun getImageUrls(keyword: String): List<String> {
            val url = "https://www.google.com/search?as_st=y&tbm=isch&hl=ja&as_q=${keyword}" +
                    "&as_epq=&as_oq=&as_eq=&imgsz=&imgar=&imgc=&imgcolor=&imgtype=&cr=&" +
                    "as_sitesearch=&safe=images&as_filetype=&tbs=sur%3Acl"
            return try {
                val document = Jsoup.connect(url).get()
                val pattern = Pattern.compile(
                    "<img.+?src=\"${TOKEN_OF_HTTP_PROTOCAL}(.+?)\".+?>")
                val targetHtml = document.html()
                val matcher = pattern.matcher(targetHtml)

                var imageUrlList = mutableListOf<String>()
                var index = 0
                while (matcher.find()){
                    val imageUrl = TOKEN_OF_HTTP_PROTOCAL + matcher.group(1)
                    println("Image URL[$index] : $imageUrl")
                    index++
                    if (index == 1) {
                        // Google画像検索のロゴ画像は除外する
                        continue
                    }
                    imageUrlList.add(imageUrl)
                }
                imageUrlList
            } catch (ex: Exception) {
                ex.printStackTrace()
                emptyList()
            }
        }
    }
}

ImageListRecyclerViewAdapter.kt
https://github.com/dk521123/EnglishCards/blob/develop/app/src/main/java/com/dk/englishcards/edit/ImageListRecyclerViewAdapter.kt

import android.view.*
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.dk.englishcards.R
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.image_list.view.*

class ImageListRecyclerViewAdapter(private val imageUrlList: List<String>) :
    RecyclerView.Adapter<ImageListRecyclerViewAdapter.ImageListViewHolder>() {
    class ImageListViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
        val image: ImageView = view.imageListImageView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageListViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val item = layoutInflater.inflate(R.layout.image_list, parent, false)
        return ImageListViewHolder(item)
    }

    override fun onBindViewHolder(holder: ImageListViewHolder, position: Int) {
        val url = this.imageUrlList[position]

        Picasso.get()
            .load(url)
            .resize(300, 300)
            .centerCrop()
            .into(holder.view.imageListImageView)
    }

    override fun getItemCount(): Int {
        return this.imageUrlList.size
    }
}

activity_show_images.xml
https://github.com/dk521123/EnglishCards/blob/develop/app/src/main/res/layout/activity_show_images.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".edit.ShowImagesActivity">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:ignore="MissingConstraints">

        <TextView
            android:id="@+id/targetEnglishWordTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:text="Target"
            android:textSize="36sp" />
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:orientation="vertical"
            tools:ignore="MissingConstraints">
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/imagesRecyclerView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scrollbars="vertical" />
        </LinearLayout>
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="horizontal"
        tools:ignore="MissingConstraints">
        <ProgressBar
            android:id="@+id/imagesProgressbar"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            style="?android:attr/progressBarStyleLarge"
            tools:ignore="MissingConstraints" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

image_list.xml
lhttps://github.com/dk521123/EnglishCards/blob/develop/app/src/main/res/layout/image_list.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    card_view:cardElevation="2dp"
    android:foreground="?android:attr/selectableItemBackground">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <ImageView
            android:id="@+id/imageListImageView"
            android:layout_width="350dp"
            android:layout_height="300dp"
            android:layout_margin="8dp" />

    </LinearLayout>
</androidx.cardview.widget.CardView>

関連記事

Kotlin / Realm で英単語帳を作る
https://dk521123.hatenablog.com/entry/2020/07/20/232009
画面コンポーネント / ImageView
https://dk521123.hatenablog.com/entry/2020/09/20/000000
画面コンポーネント / RecyclerView ~ 入門編 ~
https://dk521123.hatenablog.com/entry/2020/07/21/000000
Pythonスクレイピング ~ Beautiful Soup 4 ~
https://dk521123.hatenablog.com/entry/2020/06/01/000000