安卓 “指纹验证” 技术方案总结

参考资源汇总

不同安卓版本的市场占有率
SOTER 仓库地址
SOTER 诞生日记
阿里指纹SDK 使用申请地址
FIngerprintManager 谷歌官方示例
FingerprintManager 复杂业务的架构设计示例
FingerprintManager 和 BiometricPrompt 结合示例
androidx 迁移指南

相比于 IOS ,Android 系统中的应用适配需要更大的工作量,结果也可能不够理想,指纹验证这一块同样如此。
本文首先说几个普通的开发者可能遇到的问题和笔者的理解,然后对谷歌官方的 API 进行重点说明。

1
微信、支付宝、招商银行是怎样实现的 ,普通开发者能不能做到?

首先要说明,谷歌的 API 由于条件所限在一些极端场景中具有被黑客攻击利用的风险,在普通场景中的保密级别也是不高于锁屏密码的。原因可见 SOTER 诞生日记

在微信或支付宝中,指纹验证是用于支付业务,对安全性的要求极高,为此,腾讯和阿里分别采用了不同的标准,分别是 SOTER 和 IFAA。通过两种标准的技术方案,可以精准区分不同的手指,并且验证指纹时需要网络连接服务器进行秘钥比对。这样既保证了极端场景中的安全,也可以将指纹支付的权限只开对本人开放。

其中 SOTER 是开源的,基础功能的集成相对方便,高安全性的配置则要多费些时间。另外需要说明,由于需要与手机厂商深度合作,SOTER 标准实现的机型覆盖率可能不够理想。在上述仓库的 Wiki 中有具体的使用说明和适配的机型列表。

阿里指纹 SDK 需要申请使用权限,难度和使用方法笔者还不确定。

而在招商银行 App 中,指纹仅用于登录,实际支付过程还是需要密码,所以我推测它可能是利用谷歌官方的 API 实现的。官方 API 的使用很简单,后文会详细介绍。

2
谷歌在 6.0 版本开始为指纹识别提供官方 API,然而在这之前已经有手机厂商集成了指纹识别功能开发了自己的 API,并且在 6.0 之后依然使用。这部分厂商的手机系统要不要适配 ?

不同应用对于设备覆盖率和操作安全性的要求标准是不同的。

对安全性要求较高的话应该采用腾讯或阿里的标准,那么就无法考虑覆盖率了。

若采用谷歌的 API,可以参考 不同安卓版本的市场占有率
目前来看应该 6.0 以上版本占比十至七八,应该可以满足大部分需求,而对剩下的设备进行专门适配需要很大工作量,笔者认为除非必要无需适配。暂时也想不到又何必要,毕竟强如腾讯微信也没有做到。

3
谷歌的 API 从 6.0 到 9.0 经历了几次调整更新,最初发布的 API 已经被标记为弃用,如果计划适配 6.0 以上的系统,该怎么选用具体的 API ?

如果对指纹验证过程的界面有个性化要求,就完全使用 FingerprintMangerCompat 来实现;

若不排斥谷歌的 UI 设计风格并且考虑较长时间的稳定放心,就根据设备系统版本的判断结果,在 9.0 以上设备调用 BiometricPrompt,在其他设备调用 FingerprintManagerCompat 。

另外,除了这种在网上 demo 中多见的兼容方法,还可以通过 androidx.biometric 的相关 API 来快速实现,这算是笔者的意外发现了。

谷歌不同版本 API 的介绍及关系说明

在 Android 6.0 API 版本 23,发布了指纹验证的 API :
android.hardware.fingerprint.FingerprintManager
在 Android 7.0 API 版本 24,发布了兼容类:
android.support.v4.hardware.fingerprint.FingerprintManagerCompat
在 Android 9.0 API 版本 28,发布了生物信息综合验证的基础 API :
android.hardware.biometrics.BiometricPrompt.BiometricPrompt

通过上面三个类都可以完成调用设备硬件的指纹验证服务,之间的关系如下:

  • 只有 FingerprintManger 是真正可以调用指纹验证服务的 API,另外两个类都是通过它来实现具体功能的;
  • FingerprintManagerCompat 兼容了 6.0 以下设备,完善了相关方法内的逻辑判断;
  • 在 BiometricPrompt,集成了一个默认的 Dialog,不再需要或者说不被允许使用自定义的验证界面。也是由此 FingerprintManager 被标记为弃用。
  • 使用 FingerprintManager 需要的权限为 android.permission.USE_FINGERPRINT,而使用 BiometricPrompt 则需要 android.permission.USE_BIOMETRIC。

贴一段 FingerprintManagerCompat 的代码:

     // 判断设备是否有录入的指纹,若低于23直接返回false
    public boolean hasEnrolledFingerprints() { 
        if (VERSION.SDK_INT < 23) {
            return false;
        } else {
            FingerprintManager fp = getFingerprintManagerOrNull(this.mContext);
            return fp != null && fp.hasEnrolledFingerprints();
        }
    }
    ...
    // 包装回调接口,使验证服务结果返回给 FingerprintManagerCompat 的接口
    @RequiresApi(23)
    private static android.hardware.fingerprint.FingerprintManager.AuthenticationCallback 
                wrapCallback(final FingerprintManagerCompat.AuthenticationCallback callback) {
        return new android.hardware.fingerprint.FingerprintManager.AuthenticationCallback() {
         ...
        };
    }

再贴一点 BiometricPrompt 的代码:

    // 包含 FingerprintManager 成员变量
    private FingerprintManager mFingerprintManager;
    ...
    // 初始化时获取 FingerprintManager 实例
    private BiometricPrompt(Context context, Bundle bundle,
            ButtonInfo positiveButtonInfo, ButtonInfo negativeButtonInfo) {
        ...
        mFingerprintManager = context.getSystemService(FingerprintManager.class);
    }
    ...
    // 通过 FingerprintManager 开启指纹验证
    public void authenticate(@NonNull CryptoObject crypto,
            @NonNull CancellationSignal cancel,
            @NonNull @CallbackExecutor Executor executor,
            @NonNull AuthenticationCallback callback) {
        ...
        mFingerprintManager.authenticate(crypto, cancel, mBundle, executor, mDialogReceiver, callback);
    }

// 翻看源码未找到默认dialog的实现代码,哪位若是清楚麻烦在评论中告知。

另外,在 API 版本 28 开始计划推行 biometrics 开始,有些 fingerprint 包下的内容被移到了 biometrics 包下。
于是,不仅 BiometricPrompt 调用了 FingerprintManager,同时 FingerprintManager 也需要引入 biometrics 包下的内容。

举例说明,28 之前的 FingerprintManager 没有父子关系,内部定义了一些常量:

package android.hardware.fingerprint;
public class FingerprintManager {
    public static final int FINGERPRINT_ERROR_HW_UNAVAILABLE = 1;
    public static final int FINGERPRINT_ERROR_UNABLE_TO_PROCESS = 2;
    public static final int FINGERPRINT_ERROR_TIMEOUT = 3;
...
}

然而在 28 中,常量转移到了 biometrics 包中:

package android.hardware.biometrics;
public interface BiometricFingerprintConstants {
    public static final int FINGERPRINT_ERROR_HW_UNAVAILABLE = 1;
    public static final int FINGERPRINT_ERROR_UNABLE_TO_PROCESS = 2;
    public static final int FINGERPRINT_ERROR_TIMEOUT = 3;
...
}

再通过为 FingerprintManager 实现接口来获取常量 :

package android.hardware.fingerprint;
import android.hardware.biometrics.BiometricFingerprintConstants;
public class FingerprintManager implements BiometricFingerprintConstants {
...
}

这种双向的引用使类之间的关系变得复杂,给人带来一些困惑。
若是说为了被 BiometricPrompt 使用,明明还有另外一个 biometrics 专用的常量类:

package android.hardware.biometrics;
public interface BiometricConstants {
    int BIOMETRIC_ERROR_HW_UNAVAILABLE = 1;
    int BIOMETRIC_ERROR_UNABLE_TO_PROCESS = 2;
    int BIOMETRIC_ERROR_TIMEOUT = 3;
...
}

所以,个人觉得有点莫名其妙,不明白有何必要这样设计。

// 哪位若是清楚麻烦在评论中告知。

FingerprintManager 与 FingerprintManagerCompat 的使用

查看 FingerprintManager 源码可以发现,其实包含了很多方法和内部类,包括指纹录入、指纹删除等方法及相关的监听,但其中大部分都未向开发者开放。
谷歌创建了示例代码供大家参考学习,其中涉及了参数的创建、方法的调用、生命周期的控制以及加密的作用展示。

FingerprintManagerCompat 是前者的兼容类,从使用上来说两者只有一个差别,就是实例的创建:

    FingerprintManagerCompat compat = FingerprintManagerCompat.from(getContext());

    FingerprintManager manager = getContext().getSystemService(FingerprintManager.class);

通过实例我们能够调用的方法只有三个:

    boolean isHardwareDetected() { ... }    // 设备硬件是否支持指纹功能

    boolean hasEnrolledFingerprints() { ... }     // 是否存在已录入的指纹

    void authenticate(  // 开启指纹验证服务
                        CryptoObject crypto,     // 包含加密信息的对象
                        CancellationSignal cancel,     //  指纹验证被取消时的监听
                        int flags,     // 可选的 flag,官方推荐设为 0
                        AuthenticationCallback callback,     // 指纹验证结果的回调
                        Handler handler) { ... }    // 用于指定处理消息的线程

前两个方法分别判断硬件是否支持指纹验证和设备是否已存在录入的指纹,可以根据业务逻辑需要进行调用。
第三个方法则是调用指纹服务开始进行验证,可以看到参数比较多。

5个参数中,只有 callback 是不能为空的,很符合直觉,毕竟我们需要在获得验证结果后开始对应的业务流程。
callback 的代码非常简单,就是几个基础但必要的回调方法,两个重要方法如下:

    public static abstract class AuthenticationCallback  {

        @Override
        public void onAuthenticationError(int errorCode, CharSequence errString) { }

        @Override
        public void onAuthenticationSucceeded(AuthenticationResult result) { }

    };

通过验证失败的返回参数 errorCode 可以区分失败原因,具体的值和代表的场景意义,在上面说明 API 关系举例时提到了代码位置,常用的如下:

    public static final int FINGERPRINT_ERROR_HW_UNAVAILABLE = 1;    // 硬件出现问题
    public static final int FINGERPRINT_ERROR_UNABLE_TO_PROCESS = 2;    // 指纹验证不通过
    public static final int FINGERPRINT_ERROR_CANCELED = 5;;    // 手指移动过快,识别失败
    public static final int FINGERPRINT_ERROR_LOCKOUT = 7;    //验证错误次数超过5次时返回这个值,使 api 暂不可用

crypto 是很重要的参数,在刚接触指纹验证时可以先传个 null,熟悉下整体的使用方法。然后如果实际使用指纹验证的场景需要保证一定的安全性,就可以通过这个对象,利用 Cipher 等产生秘钥,并且在发生指纹变化或设备锁屏密码解除时得到反馈,从而中断正在进行的验证。
可以通过谷歌的示例代码或一些网上的博客示例代码来进行配置,在本文后面也会专门对加密这一部分进行说明。

cancel 参数使我们可以手动停止指纹验证,比如当页面进入后台或者 onPause() 方法被调用时,应该手动停止服务。
最后, handler 可以指定一个线程来处理验证结果,没有必要时可以直接传入 null,默认设置为主线程。

以上就是使用 FingerprintManager 时必须要知道的内容了:获取实例,创建参数对象,调用方法。
这么说起来比较简单,但是除此之外,需要自己根据业务场景来实现对应的界面,并且可能需要在 Activity 或 Fragment 的不同生命周期进行服务的开启或关闭、资源的创建或释放,总的来说还是有一些工作量的。

推荐参考:
FIngerprintManager 谷歌官方示例
FIngerprintManager 复杂业务的架构设计示例

BiometricPrompt 的使用

因为 biometrics 相关的 API 中已经实现了界面的定制,使用时可以更轻松。
首先实例的初始化,这里采用了 Builder 模式。私有化构造方法,然后通过静态内部类进行对象的创建和参数的设置:

    public static class Builder {
        ...
        public Builder(Context context) {...}

        public Builder setTitle(@NonNull CharSequence title) {... return this;}
        public Builder setSubtitle(@NonNull CharSequence subtitle) {... return this;}
        public Builder setDescription(@NonNull CharSequence description) {... return this;}

        public Builder setPositiveButton(@NonNull CharSequence text,
                @NonNull @CallbackExecutor Executor executor,
                @NonNull DialogInterface.OnClickListener listener) {...return this;}

        public Builder setNegativeButton(@NonNull CharSequence text,
                @NonNull @CallbackExecutor Executor executor,
                @NonNull DialogInterface.OnClickListener listener) {... return this;}

        public BiometricPrompt build() {
            ...
            return new BiometricPrompt(mContext, mBundle, mPositiveButtonInfo, mNegativeButtonInfo);
        }
    }

使用的时候可以这样:

    BiometricPrompt  mBiometricPrompt = new BiometricPrompt
             .Builder(...)
             .setTitle(...)
             .setDescription(...)
             .setSubtitle(...)
             .setNegativeButton(...)
             .build();

有了对象实例,准备工作就完成了一半,剩下一半需要了解这个调用指纹服务的方法:

    public void authenticate(
            @NonNull CryptoObject crypto,
            @NonNull CancellationSignal cancel,
            @NonNull @CallbackExecutor Executor executor,
            @NonNull AuthenticationCallback callback) {
        ...
        mFingerprintManager.authenticate(...);
    }

可以看到,方法的参数同 FingerprintManager 中讲到的几乎一样,方法内部也是通过 FingerprintManager 成员对象来完成具体的操作。
参数的不同主要体现在用 executor 替换掉了 handler,但实际上作用还是用来控制线程。

public interface Executor {
    void execute(Runnable command);
}

Executor 涉及了线程池方面的内容,若无特殊需求可以通过 Context 的 getMainExecutor() 方法来指定主线程。

另外还有几个重载的方法,参数就是上面几个的组合,可以根据需要进行选择,不再赘述。

使用 BiometricPrompt 进行指纹验证当然有个前提,就是设备的 API 版本在 28 以上。那目前根据各版本的市场占有率来看,需要对不支持的设备进行兼容。
kleyui方法就是自己进行版本判断,然后 FingerprintManager 和 BiometricPrompt 完成两套代码逻辑,网上也有很多人是这么做的可以参考:FingerprintManager 和 BiometricPrompt 结合示例
另外,在搜集相关资料的过程中,发现谷歌在androidx中也采取了这种解决方案。

androidx.Biometrics

https://mvnrepository.com/artifact/androidx.biometric/biometric/1.0.0-alpha04
通过上面的链接可以看到,这一部分内容还没有正式版,并且在不断更新中。感兴趣的同学可以在项目中集成,然后阅读下源码。
在我的DemoCenter项目中摘取了相关代码并做了一点封装,可以用来参考。

附 1:KeyStore、Key 、Cipher 与 CryptoObject

    private static final String SECRET_MESSAGE = "Very secret message";
    private static final String KEY_NAME_NOT_INVALIDATED = "key_not_invalidated";
    static final String DEFAULT_KEY_NAME = "default_key";

    private KeyStore mKeyStore;
    private KeyGenerator mKeyGenerator;
        try {
            mKeyStore = KeyStore.getInstance("AndroidKeyStore");
        } catch (KeyStoreException e) {
            throw new RuntimeException("Failed to get an instance of KeyStore", e);
        }
        try {
            mKeyGenerator = KeyGenerator
                    .getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
        } catch (NoSuchAlgorithmException | NoSuchProviderException e) {
            throw new RuntimeException("Failed to get an instance of KeyGenerator", e);
        }
        
        Cipher cipher;
        try {
            cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
                    + KeyProperties.BLOCK_MODE_CBC + "/"
                    + KeyProperties.ENCRYPTION_PADDING_PKCS7);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
            throw new RuntimeException("Failed to get an instance of Cipher", e);
        }
        ...
        createKey(DEFAULT_KEY_NAME, true);
    /**
     * Creates a symmetric key in the Android Key Store which can only be used after the user has
     * authenticated with fingerprint.
     *
     * @param keyName the name of the key to be created
     * @param invalidatedByBiometricEnrollment if {@code false} is passed, the created key will not
     *                                         be invalidated even if a new fingerprint is enrolled.
     *                                         The default value is {@code true}, so passing
     *                                         {@code true} doesn't change the behavior
     *                                         (the key will be invalidated if a new fingerprint is
     *                                         enrolled.). Note that this parameter is only valid if
     *                                         the app works on Android N developer preview.
     *
     */
    public void createKey(String keyName, boolean invalidatedByBiometricEnrollment) {
        // The enrolling flow for fingerprint. This is where you ask the user to set up fingerprint
        // for your flow. Use of keys is necessary if you need to know if the set of
        // enrolled fingerprints has changed.
        try {
            mKeyStore.load(null);
            // Set the alias of the entry in Android KeyStore where the key will appear
            // and the constrains (purposes) in the constructor of the Builder

            KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(keyName,
                    KeyProperties.PURPOSE_ENCRYPT |
                            KeyProperties.PURPOSE_DECRYPT)
                    .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                    // Require the user to authenticate with a fingerprint to authorize every use
                    // of the key
                    .setUserAuthenticationRequired(true)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7);

            // This is a workaround to avoid crashes on devices whose API level is < 24
            // because KeyGenParameterSpec.Builder#setInvalidatedByBiometricEnrollment is only
            // visible on API level +24.
            // Ideally there should be a compat library for KeyGenParameterSpec.Builder but
            // which isn't available yet.
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                builder.setInvalidatedByBiometricEnrollment(invalidatedByBiometricEnrollment);
            }
            mKeyGenerator.init(builder.build());
            mKeyGenerator.generateKey();
        } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException
                | CertificateException | IOException e) {
            throw new RuntimeException(e);
        }
    }
    /**
     * Initialize the {@link Cipher} instance with the created key in the
     * {@link #createKey(String, boolean)} method.
     *
     * @param keyName the key name to init the cipher
     * @return {@code true} if initialization is successful, {@code false} if the lock screen has
     * been disabled or reset after the key was generated, or if a fingerprint got enrolled after
     * the key was generated.
     */
    private boolean initCipher(Cipher cipher, String keyName) {
        try {
            mKeyStore.load(null);
            SecretKey key = (SecretKey) mKeyStore.getKey(keyName, null);
            cipher.init(Cipher.ENCRYPT_MODE, key);
            return true;
        } catch (KeyPermanentlyInvalidatedException e) {
            return false;
        } catch (KeyStoreException | CertificateException | UnrecoverableKeyException | IOException
                | NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("Failed to init Cipher", e);
        }
    }

附 2:AndroidX 迁移

当依赖中同时包括 support 系列的内容和 androidx 的内容时,编译过程会报错:

Manifest merger failed : Attribute application@appComponentFactory value=(android.support.v4.app.CoreComponentFactory) 
    from [com.android.support:support-compat:28.0.0] AndroidManifest.xml:22:18-91
    is also present at [androidx.core:core:1.0.0] AndroidManifest.xml:22:18-86 value (androidx.core.app.CoreComponentFactory).
    Suggestion: add 'tools:replace="android:appComponentFactory"' to <application> element at AndroidManifest.xml:6:5-24:19 to override.

按照提示所说在 <application> 标签内添加一个设定并不能解决问题,经过搜索,发现谷歌对迁移到 AndroidX 已经有了一些支持和说明,详见 https://developer.android.com/jetpack/androidx/migrate#migrate
根据上面的教程,可以解决上述共存冲突问题,当然这意味着整个项目过渡到 AndroidX,这会增加一些工作量,另外不清楚后续会不会有其他问题。
首先,需要在 gradle.properties 中添加两行代码:

android.useAndroidX=true
android.enableJetifier=true

接下来,AndroidStudio 可以帮我们将 build.gradle 中的 support 依赖直接转换为 androidx 对应的依赖,方法是
在菜单栏中选择 Refactor > Migrate to AndroidX 。在这一步之后,可能会发现依然编译报错,原因是代码中直接引入了 support 的 api,如下所示:

这时候就不得不手动进行替换了,在上面的链接中有迁移后前后包名的映射表,找到报错的旧包名对应的新包名,然后利用 AndrodStudio 全局搜索替换即可。大致规则就是 android 替换为 andoridx,或者 android.support 替换为 androidx。完成替换后就可以编译成功了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容