Android NFC開発の詳細な概要とNFCカード読み取り例の分析


序文

モノのインターネット企業の Android ソフトウェア層の開発には、物流、セキュリティ、医療、教育などの業界を問わず、SDK を直接呼び出す方法を除いて、ハードウェアと通信するステップが必要です。ハードウェア通信に直接関係するものは最も一般的に使用されるものにすぎず、シリアル通信、USB 通信、Bluetooth、赤外線、NFC などのいくつかの種類があります。

シリアルポートとUSB通信技術については、これまでの記事「シリアル通信開発の概要と分析例USB通信開発の概要とサーマルプリンタ開発の分析例」で紹介しました。この記事では、NFC 開発に関連するテクノロジーとその迂回路を記録し、困っている人々を助けるとともに、自分自身の知識を定着させることを目指しています。

開発概要に加えて、この記事のケースはNFC読み取りM1カードです。
実際、カードの読み取り要件については、SDKを呼び出す場合とAndroidに付属しているNFC機能を使用する場合に大きく分けて研究開発を行っています


1.NFCとは何ですか?

NFC は現在、Android 携帯電話の構成ハードウェアの主流となっており、正式名称は Near Field Communication (近距離無線通信) で、中間の近距離無線通信技術は近距離無線通信技術とも呼ばれます。NFC テクノロジーを使用するデバイス (携帯電話など) は、互いに近づくとデータを交換できます。このテクノロジーは、非接触型 RFID (Radio Frequency Identification) と相互接続テクノロジーの統合から発展しました。

2. 基礎知識

開発を始める前に知っておくべきこと

1.NDEFとは何ですか?

NFC タグに保存されるデータはさまざまな形式で書き込むことができますが、多くの Android フレームワーク API は、NDEF (NFC Data Exchange Format) と呼ばれる NFC フォーラム標準に基づいています。

簡単に言えば、共通のデータ形式規格です。

2. NFC技術の動作モード

(1) リーダー/ライター モード: パッシブ NFC タグおよびステッカーの読み取りおよび/または書き込みを行う NFC デバイスをサポートします。
(2) ピアツーピア モード: NFC デバイスが他の NFC ピア デバイスとデータを交換できるようにサポートします。Android ビームはこの動作モードを使用します。
(3) カードエミュレーションモード: NFC デバイス自体を NFC カードとしてサポートします。エミュレートされた NFC カードは、NFC POS などの外部 NFC リーダーからアクセスできます。

このケースの主な用途は、カードの読み取りと書き込みです。これは、カードの読み取りと書き込みの通常の需要です。後で、ポイントツーポイントとカードのシミュレーションのニーズに触れる機会があれば、この記事で補足をします。

3. ラベルの技術的タイプ

通常、タグ (カード) の各カテゴリは 1 つ以上のテクノロジーをサポートしており、
対応関係は次のとおりです。

テクノロジー 説明 カードの種類
NfcA NFC-A (ISO 14443-3A) 機能と I/O 操作へのアクセスを提供します。 M1カード
NfcB NFC-B (ISO 14443-3B) 機能と I/O 操作へのアクセスを提供します。
NFC NFC-F (JIS 6319-4) 機能と I/O 操作へのアクセスを提供します。
NfcV NFC-V (ISO 15693) 機能と I/O 操作へのアクセスを提供します。 15693 枚のカード
イソデップ ISO-DEP (ISO 14443-4) 機能と I/O 操作へのアクセスを提供します。 CPUカード
Ndef NDEF としてフォーマットされた NFC タグのデータへのアクセスと操作を提供します。
NdefFormatable NDEF としてフォーマットできるフォーマットテーブルを提供するタグ。
Mifareクラシック この Android デバイスが MIFARE をサポートしている場合、MIFARE Classic のパフォーマンスと I/O 操作へのアクセスが提供されます。 m1カード
Mifare超軽量 この Android デバイスが MIFARE をサポートしている場合、超軽量パフォーマンスと I/O 操作のために MIFARE にアクセスできます。

下図に示すように、デモで表示されるNFCタグの情報です。
私の丸で囲った部分がこのNFCタグがサポートする技術で、後ほどデータを解析する際に利用しますので、これらを取得した後、対応するクラスを利用してタグデータを解析することができます。
ここに画像の説明を挿入
開発中に、このタグでサポートされている解析メソッドを取得するための対応するメソッドが用意されています。これについては後ほど紹介します。

4. 実施方法の分類

(1) マニフェスト登録方法: この方法は主に、さまざまな種類の NFC アクションに応答するために、マニフェスト ファイルに対応するアクティビティの下にフィルターを構成します。この方法を使用すると、カードをスワイプするときに、携帯電話に NFC を実装したアプリケーションが複数ある場合、システムはユーザーが選択できる NFC イベントに応答できるアプリケーションのリストをポップアップ表示します。ユーザーは次のことを行う必要があります。ターゲット アプリケーションをクリックして、この NFC カード スワイプ イベントに応答します。

(2) フォアグラウンド応答メソッドでは、マニフェストでフィルターを再構成する必要はなく、フォアグラウンド アクティビティを直接使用して NFC イベントをキャプチャし、応答します。

異なる
応答方法: マニフェストに登録された NFC イベントはシステムによって配布され、イベントに応答するアプリケーションを選択する必要がある フォアグラウンド応答方法は、NFC イベントに
       応答するフォアグラウンド アクティビティによってキャプチャされます優先度の違い
マニフェストに登録されているレスポンスメソッドよりもフォアグラウンド応答メソッドの優先度が高いモード
     (マニフェストに登録されているアプリとフォアグラウンドキャプチャモードのアプリが複数インストールされている場合、カードをスワイプした後の優先度が最も高いものが優先されます)フォアグラウンドでキャプチャされた場合、フォアグラウンドで対応するアプリが開いていない場合は、ユーザーがマニフェスト認定アプリに登録するかどうかを選択するためのリストがポップアップ表示されます)

最初のタイプは、カードをスワイプして呼び出す必要があるアプリに適しており、デバイスには NFC タグ プログラムに応答する複数の IoT デバイスがありません (カードには APP、WeChat などのパッケージが含まれているため、アプリを使用する場合)操作が面倒になります)

2 番目のタイプは、フロントエンド インターフェイスでのカード読み取りに適しており、複数のアプリケーションがある場合は、
独自のプロジェクト要件に応じて適切な実装方法を選択します。

5. プロセス

まず、デバイスが NFC 認証をサポートしているという前提の下で、どの方法が使用されるかに関係なく、最初にカードがスワイプされ、システムは対応するアクティビティを配布してタグを取得するか、フォアグラウンドのアクティビティがタグをキャプチャします。次に、このタグでサポートされているテクノロジーに従ってデータを解析します。

3. ラベルの内容を取得する

1. 環境を確認する

まずマニフェストに権限を追加します

    <uses-permission android:name="android.permission.NFC" />

NFCをサポートするかどうかを決定し、機能を開きます

 NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this);
        if (null == adapter) {
    
    
            Toast.makeText(this, "不支持NFC功能", Toast.LENGTH_SHORT).show();
        } else if (!adapter.isEnabled()) {
    
    
            Intent intent = new Intent(Settings.ACTION_NFC_SETTINGS);
            // 根据包名打开对应的设置界面
            startActivity(intent);
        }

 

2. NFCタグを取得する

2.1 マニフェストに登録してタグを取得する

インテントフィルタは3種類あり、
その特徴は前回の「実装形態の分類」で紹介しましたが、タグスケジューリングシステムによる配布方法はマニフェストに固定のインテントフィルタを定義する必要があります。タグ スケジューリング システムでは 3 つのインテントが定義されており、優先順位の高いものから低いものの順に次のようにリストされています。

ACTION_NDEF_DISCOVERED : NDEF ペイロードを含むタグがスキャンされ、そのタイプが認識された場合、このインテントを使用してアクティビティを開始します。これは最も優先度の高いインテントであり、ラベル スケジューリング システムは、可能な限りこのインテントを使用してアクティビティを開始しようとし、失敗した場合は他のインテントを使用しようとします。

ACTION_TECH_DISCOVERED : ACTION_NDEF_DISCOVERED インテントを処理するために登録されたアクティビティがない場合、タブ ディスパッチ システムはこのインテントを使用してアプリケーションを起動しようとします。さらに、スキャンされたタグに MIME タイプまたは URI にマッピングできない NDEF データが含まれている場合、またはタグに NDEF データが含まれていないが既知のタグ テクノロジが使用されている場合、このインテントも (最初に ACTION_NDEF_DISCOVERED を起動せずに) 直接起動されます。

ACTION_TAG_DISCOVERED : ACTION_NDEF_DISCOVERED または ACTION_TECH_DISCOVERED インテントを処理するアクティビティがない場合は、このインテントを使用してアクティビティを開始します。

インテント フィルターを追加する
これは、最初に最も単純で優先度が最も高いフィルターであり、すでにニーズを満たしています。

        <activity
            android:name=".NfcActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="android.nfc.action.NDEF_DISCOVERED" />
            </intent-filter>
        </activity>

もちろん2番目を選択することもできます

        <activity
            android:name=".NfcActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="android.nfc.action.TECH_DISCOVERED" />
            </intent-filter>
            <meta-data android:name="android.nfc.action.TECH_DISCOVERED"
                android:resource="@xml/filter_nfc" />
        </activity>

filter_nfc
ファイルは TECH_DISCOVERED が設定する必要があるファイルであり、このうち、tech-list 間のロジックや関係、tech 間のロジックや関係は、スキーム 2 の techList の原理と使用方法に似ています。

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:android="http://schemas.android.com/apk/res/android">
 
    <tech-list>
        <tech>android.nfc.tech.Ndef</tech>
        <tech>android.nfc.tech.NfcA</tech>
    </tech-list>
 
    <tech-list>
        <tech>android.nfc.tech.NfcB</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcF</tech>
    </tech-list>
</resources>

最後に残った

        <activity
            android:name=".NfcActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="android.nfc.action.TAG_DISCOVERED"/>
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>
        </activity>

一般的には役に立たないということはあまり意味がありません。

次に、対応するアクティビティの onCreate メソッドでラベルを取得できます。

class NfcActivity : AppCompatActivity() {
    
    

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_nfc)
        val adapter = NfcAdapter.getDefaultAdapter(this)
        if (null == adapter) {
    
    
            Toast.makeText(this, "不支持NFC功能", Toast.LENGTH_SHORT).show()
        } else if (!adapter.isEnabled) {
    
    
            val intent = Intent(Settings.ACTION_NFC_SETTINGS)
            // 根据包名打开对应的设置界面
            startActivity(intent)
        }
        val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
    }
}

2.1 フォアグラウンドアクティビティキャプチャによるタグの取得

class MainActivity : AppCompatActivity() {
    
    
    var mNfcAdapter: NfcAdapter? = null
    var pIntent: PendingIntent? = null

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
       super.onCreate(savedInstanceState)
       initNfc()
 

    }
    private fun initNfc() {
    
    
        mNfcAdapter = M1CardUtils.isNfcAble(this)
        pIntent = PendingIntent.getActivity(this, 0,  
        //在Manifest里或者这里设置当前activity启动模式,否则每次响应NFC事件,activity会重复创建
        Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), 0)
    }
    
    override fun onResume() {
    
    
        super.onResume()
        mNfcAdapter?.let {
    
    
            val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
            val tag = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
            val tech = IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)
            val filters = arrayOf(ndef, tag, tech)
            val techList = arrayOf(
                arrayOf(
                    "android.nfc.tech.Ndef",
                    "android.nfc.tech.NfcA",
                    "android.nfc.tech.NfcB",
                    "android.nfc.tech.NfcF",
                    "android.nfc.tech.NfcV",
                    "android.nfc.tech.NdefFormatable",
                    "android.nfc.tech.MifareClassic",
                    "android.nfc.tech.MifareUltralight",
                    "android.nfc.tech.NfcBarcode"
                )
            )
            it.enableForegroundDispatch(this, pIntent, filters, techList)
            XLog.d("开始捕获NFC数据")
        }
    }
    override fun onPause() {
    
    
        super.onPause()
        mNfcAdapter?.disableForegroundDispatch(this)
    }
    override fun onNewIntent(intent: Intent?) {
    
    
        super.onNewIntent(intent)
        //这里必须setIntent,set  NFC事件响应后的intent才能拿到数据
        setIntent(intent)
        val tag = getIntent().getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
        //M1CardUtils 我后面会贴出来的
        if (M1CardUtils.isMifareClassic(tag)) {
    
    
            try {
    
    
                val reader = M1CardUtils.readCard(tag)
                XLog.d("读卡内容:$reader")
                val data = reader.split("|")
            } catch (e: IOException) {
    
    
                e.printStackTrace()
            }
        }
    }
}

4番目に、ラベルデータを分析します

どちらの方法を使用しても、TAG タグを取得するときの解析方法は同じであり、カードの種類に応じて対応する解析方法を選択する必要があります。図に示すように、カードの情報を取得できます
ここに画像の説明を挿入
。対応するもの:
サポートされているテクノロジ タイプ
MifareClassic タイプ
セクター ストレージ スペース
セクター数
セクター内のブロック数

1. M1カードの分析

ここで基本的なことをお話しますと、NFC であれ、カードリーダーモジュールの読み取りであれ、分析プロセスは、まずカードを見つけ、次にセクターのパスワードを検証し、セクターのデータを取得することです。読み取られるデータはセクタ 2 にあることがわかっているので、カードを検証するときに、検証するセクタ番号、セクタのパスワード、およびセクタの検証パスワード タイプ A/B を渡します。 、データを読み取ることができます。


import android.app.Activity
import android.nfc.NfcAdapter
import android.nfc.Tag
import com.hjq.toast.ToastUtils
import kotlin.Throws
import android.nfc.tech.MifareClassic
import com.elvishew.xlog.XLog
import java.io.IOException
import java.lang.StringBuilder
import java.nio.charset.Charset


object M1CardUtils {
    
    
    /**
     * 判断是否支持NFC
     *
     * @return
     */
    fun isNfcAble(mContext: Activity?): NfcAdapter? {
    
    
        val mNfcAdapter = NfcAdapter.getDefaultAdapter(mContext)
        if (mNfcAdapter == null) {
    
    
            ToastUtils.show("设备不支持NFC!")
        }
        if (!mNfcAdapter!!.isEnabled) {
    
    
            ToastUtils.show("请在系统设置中先启用NFC功能!")
        }
        return mNfcAdapter
    }

    /**
     * 监测是否支持MifareClassic
     *
     * @param tag
     * @return
     */
    fun isMifareClassic(tag: Tag): Boolean {
    
    
        val techList = tag.techList
        var haveMifareUltralight = false
        for (tech in techList) {
    
    
            if (tech.contains("MifareClassic")) {
    
    
                haveMifareUltralight = true
                break
            }
        }
        if (!haveMifareUltralight) {
    
    
            ToastUtils.show("不支持MifareClassic")
            return false
        }
        return true
    }

    /**
     * 读取卡片信息
     *
     * @return
     */
    @Throws(IOException::class)
    fun readCard(tag: Tag?): String {
    
    
        val mifareClassic = MifareClassic.get(tag)
        return try {
    
    
            mifareClassic.connect()
            val metaInfo = StringBuilder()
            val gbk = Charset.forName("gbk")

            // 获取TAG中包含的扇区数
            val sectorCount = mifareClassic.sectorCount
            //            for (int j = 0; j < sectorCount; j++) {
    
    
            val bCount: Int //当前扇区的块数
            var bIndex: Int //当前扇区第一块
            if (m1Auth(mifareClassic, 2)) {
    
    
                bCount = mifareClassic.getBlockCountInSector(2)
                bIndex = mifareClassic.sectorToBlock(2)
                var length = 0
                for (i in 0 until bCount) {
    
    
                    val data = mifareClassic.readBlock(bIndex)
                    for (i1 in data.indices) {
    
    
                        if (data[i1] == 0.toByte()) {
    
    
                            length = i1
                        }
                    }
                    val dataString = String(data, 0, length, gbk).trim {
    
     it <= ' ' }
                    metaInfo.append(dataString)
                    bIndex++
                }
            } else {
    
    
                XLog.e("密码校验失败")
            }
            //            }
            metaInfo.toString()
        } catch (e: IOException) {
    
    
            throw IOException(e)
        } finally {
    
    
            try {
    
    
                mifareClassic.close()
            } catch (e: IOException) {
    
    
                throw IOException(e)
            }
        }
    }

    /**
     * 改写数据
     *
     * @param block
     * @param blockbyte
     */
    @Throws(IOException::class)
    fun writeBlock(tag: Tag?, block: Int, blockbyte: ByteArray?): Boolean {
    
    
        val mifareClassic = MifareClassic.get(tag)
        try {
    
    
            mifareClassic.connect()
            if (m1Auth(mifareClassic, block / 4)) {
    
    
                mifareClassic.writeBlock(block, blockbyte)
                XLog.e("writeBlock", "写入成功")
            } else {
    
    
                XLog.e("密码是", "没有找到密码")
                return false
            }
        } catch (e: IOException) {
    
    
            throw IOException(e)
        } finally {
    
    
            try {
    
    
                mifareClassic.close()
            } catch (e: IOException) {
    
    
                throw IOException(e)
            }
        }
        return true
    }

    /**
     * 密码校验
     *
     * @param mTag
     * @param position
     * @return
     * @throws IOException
     */
    @Throws(IOException::class)
    fun m1Auth(mTag: MifareClassic, position: Int): Boolean {
    
    
        if (mTag.authenticateSectorWithKeyA(position, MifareClassic.KEY_DEFAULT)) {
    
    
            return true
        } else if (mTag.authenticateSectorWithKeyB(position, MifareClassic.KEY_DEFAULT)) {
    
    
            return true
        }
        return false
    }


}

2. ISO15693カードの解析

この場合は使用しませんが、M1 が必要なので不要です。他の大手が参考のためにカプセル化したクラスです。

import android.nfc.tech.NfcV;
 
import com.haiheng.core.util.ByteUtils;
 
import java.io.IOException;
 
/**
 * NfcV(ISO 15693)读写操作
 *   用法
 *  NfcV mNfcV = NfcV.get(tag);
 *  mNfcV.connect();
 * <p>
 *  NfcVUtils mNfcVutil = new NfcVUtils(mNfcV);
 *  取得UID
 *  mNfcVutil.getUID();
 *  读取block在1位置的内容
 *  mNfcVutil.readOneBlock(1);
 *  从位置7开始读2个block的内容
 *  mNfcVutil.readBlocks(7, 2);
 *  取得block的个数
 *  mNfcVutil.getBlockNumber();
 *  取得1个block的长度
 *  mNfcVutil.getOneBlockSize();
 *  往位置1的block写内容
 *  mNfcVutil.writeBlock(1, new byte[]{0, 0, 0, 0})
 *
 * @author Kelly
 * @version 1.0.0
 * @filename NfcVUtils.java
 * @time 2018/10/30 10:29
 * @copyright(C) 2018 song
 */
public class NfcVUtils {
    
    
    private NfcV mNfcV;
    /**
     * UID数组行式
     */
    private byte[] ID;
    private String UID;
    private String DSFID;
    private String AFI;
    /**
     * block的个数
     */
    private int blockNumber;
    /**
     * 一个block长度
     */
    private int oneBlockSize;
    /**
     * 信息
     */
    private byte[] infoRmation;
 
    /**
     *  * 初始化
     *  * @param mNfcV NfcV对象
     *  * @throws IOException
     *  
     */
    public NfcVUtils(NfcV mNfcV) throws IOException {
    
    
        this.mNfcV = mNfcV;
        ID = this.mNfcV.getTag().getId();
        byte[] uid = new byte[ID.length];
        int j = 0;
        for (int i = ID.length - 1; i >= 0; i--) {
    
    
            uid[j] = ID[i];
            j++;
        }
        this.UID = ByteUtils.byteArrToHexString(uid);
        getInfoRmation();
    }
 
    public String getUID() {
    
    
        return UID;
    }
 
    /**
     *  * 取得标签信息 
     *  
     */
    private byte[] getInfoRmation() throws IOException {
    
    
        byte[] cmd = new byte[10];
        cmd[0] = (byte) 0x22; // flag
        cmd[1] = (byte) 0x2B; // command
        System.arraycopy(ID, 0, cmd, 2, ID.length); // UID
        infoRmation = mNfcV.transceive(cmd);
        blockNumber = infoRmation[12];
        oneBlockSize = infoRmation[13];
        AFI = ByteUtils.byteArrToHexString(new byte[]{
    
    infoRmation[11]});
        DSFID = ByteUtils.byteArrToHexString(new byte[]{
    
    infoRmation[10]});
        return infoRmation;
    }
 
    public String getDSFID() {
    
    
        return DSFID;
    }
 
    public String getAFI() {
    
    
        return AFI;
    }
 
    public int getBlockNumber() {
    
    
        return blockNumber + 1;
    }
 
    public int getOneBlockSize() {
    
    
        return oneBlockSize + 1;
    }
 
    /**
     *  * 读取一个位置在position的block
     *  * @param position 要读取的block位置
     *  * @return 返回内容字符串
     *  * @throws IOException
     *  
     */
    public String readOneBlock(int position) throws IOException {
    
    
        byte cmd[] = new byte[11];
        cmd[0] = (byte) 0x22;
        cmd[1] = (byte) 0x20;
        System.arraycopy(ID, 0, cmd, 2, ID.length); // UID
        cmd[10] = (byte) position;
        byte res[] = mNfcV.transceive(cmd);
        if (res[0] == 0x00) {
    
    
            byte block[] = new byte[res.length - 1];
            System.arraycopy(res, 1, block, 0, res.length - 1);
            return ByteUtils.byteArrToHexString(block);
        }
        return null;
    }
 
    /**
     *  * 读取从begin开始end个block
     *  * begin + count 不能超过blockNumber
     *  * @param begin block开始位置
     *  * @param count 读取block数量
     *  * @return 返回内容字符串
     *  * @throws IOException
     *  
     */
    public String readBlocks(int begin, int count) throws IOException {
    
    
        if ((begin + count) > blockNumber) {
    
    
            count = blockNumber - begin;
        }
        StringBuffer data = new StringBuffer();
        for (int i = begin; i < count + begin; i++) {
    
    
            data.append(readOneBlock(i));
        }
        return data.toString();
    }
 
 
    /**
     *  * 将数据写入到block,
     *  * @param position 要写内容的block位置
     *  * @param data 要写的内容,必须长度为blockOneSize
     *  * @return false为写入失败,true为写入成功
     *  * @throws IOException 
     *  
     */
    public boolean writeBlock(int position, byte[] data) throws IOException {
    
    
        byte cmd[] = new byte[15];
        cmd[0] = (byte) 0x22;
        cmd[1] = (byte) 0x21;
        System.arraycopy(ID, 0, cmd, 2, ID.length); // UID
        //block
        cmd[10] = (byte) position;
        //value
        System.arraycopy(data, 0, cmd, 11, data.length);
        byte[] rsp = mNfcV.transceive(cmd);
        if (rsp[0] == 0x00)
            return true;
        return false;
    }
}

要約する

本日お話したいのは以上です 記事に誤りや改善点がございましたら、追加・修正を歓迎します この記事では NFC の使い方と M1 カードの読み取り・解析シナリオを紹介するだけです NFC の歴史、カード タイプ、およびインテント フィルター タイプについて詳しく説明されています。説明やその他の使用シナリオなどについては、より多くのドキュメントを参照してください。ここでは、私が見た役立つ記事をいくつか紹介します。さらに参照してください。

NFCの各種カードの種類、違い、歴史紹介
https://zhuanlan.zhihu.com/p/344426747
各種公式資料の中国語説明
https://blog.csdn.net/u013164293/article/details/124474247?spm=1001.2014 。 3001.5506

おすすめ

転載: blog.csdn.net/qq_39178733/article/details/129850034
おすすめ