背景
- 一般在我们项目中都或多或少会使用到采集设备信息的代码,也会集成第三方的SDK,第三方的SDK大部分都会采集设备信息,而国家政策要求应用在启动时需要用户同意你的应用采集设备信息后,才能继续为用户服务,市面上大多数的应用处理是在启动页里面就弹出隐私政策弹窗,让用户选择同意或拒绝,也有部分应用提供了不采集设备信息的版本,等用户同意了才能使用更多的功能
- 之前有发过一篇WebView加载Html界面会自动获取设备信息的文章:https://blog.csdn.net/qq_19942717/article/details/126797591,里面有说明如何解决这个问题,后面我对这个隐私政策问题的解决方案进行了封装,你可以根据你们的具体需求对这个库进行简单修改就可以运用到你的项目中了,本文主要还是需要搞懂里面的思想
- 下面我们就来实现在启动页去检测用户是否同意了隐私协议
解决方案
- 其他不多说了,我们直接讲实现代码,首先新建一个项目,比如叫PrivacyProtocol,让后再新建一个LaunchActivity,如下
package com.lcq.privacy; import android.animation.Animator; import android.animation.ValueAnimator; import android.app.Activity; import android.content.Intent; import android.graphics.Color; import android.os.Bundle; import android.view.Window; import android.view.WindowManager; import android.view.animation.Interpolator; import android.widget.ImageView; import com.lcq.privacysupport.PrivacyCallback; import com.lcq.privacysupport.Privacy; public class LaunchActivity extends Activity { protected ImageView img; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (!isTaskRoot()) {//防止应用切换后台后,启动页再次被拉起 final Intent intent = getIntent(); final String intentAction = intent.getAction(); if (intent.hasCategory(Intent.CATEGORY_LAUNCHER) && intentAction != null && intentAction.equals(Intent.ACTION_MAIN)) { finish(); return; } } //设置全屏 this.requestWindowFeature(Window.FEATURE_NO_TITLE); this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); setContentView(R.layout.activity_launch); img = findViewById(R.id.iv); img.setAlpha(0f); img.setBackgroundColor(Color.WHITE); startAnim(); } /** * 开始3秒的启动页动画 */ public void startAnim() { ValueAnimator va = ValueAnimator.ofFloat(0, 0.3f, 0.8f, 1f); va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float params = (float) animation.getAnimatedValue(); img.setAlpha(params); } }); va.setInterpolator(new Interpolator() { @Override public float getInterpolation(float input) { return input; } }); va.setDuration(3000); va.setRepeatCount(0); va.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { //动画结束后进行隐私协议检查,收到onAgree回调后才启动主界面,并finish自身 new Privacy(LaunchActivity.this).check(new PrivacyCallback() { @Override public void onAgree() { startActivity(new Intent(LaunchActivity.this, MainActivity.class)); finish(); } }); } @Override public void onAnimationCancel(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } }); va.start(); } @Override public void onBackPressed() { //启动页返回键不finish } }
onCreate里面进行了防止应用切换后台后,启动页再次被拉起的处理,设置为全屏,并设置了一个秒的动画,动画结束后new 一个Privacy,调用check方法,传递一个PrivacyCallback,当收到onAgree回调后,启动主页面并finish掉自身,监听启动页返回键,不让返回键退出启动页,记得将此Activity配置到AndroidManifest.xml中,启动页的布局文件如下:
<?xml version="1.0" encoding="utf-8"?> <ImageView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/iv" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@android:color/black" android:scaleType="centerCrop" android:src="@drawable/launch_ic" />
启动页的布局放一张图片就可以了
-
新建一个主界面MainActivity,设置activity_main布局主界面的布局就放一个EditText,一个Button,点击Button可以根据EditText里面的文本去启动对应的协议界面,user,policy,third 是预定义的协议类型,分别表示用户协议,隐私政策、第三方信息共享清单,这个你可以自定义,一般是服务返回给前端,前端不做具体定义,后续拓展其他的协议类型,由服务器加就可以了,代码如下
package com.lcq.privacy; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.view.View; import android.widget.EditText; import com.lcq.privacysupport.Privacy; public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); EditText editText = findViewById(R.id.et); findViewById(R.id.bt).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { new Privacy(MainActivity.this).showProtocol(editText.getText().toString().trim()); } }); } }
MainActivity的布局文件如下:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent"> <EditText android:id="@+id/et" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="user,policy,third" android:text="user" /> <Button android:id="@+id/bt" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="展示协议界面" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
-
可以看到app项目里面就只用到了Privacy的check和showProtocol两个方法,这两个方法基本满足了我们的隐私协议政策需求,Privacy类在新建的PrivacySupport模块中,当然你也可以重构到app应用模块里面,我们独立出来主要是为了将业务分割出来,如果其他项目也有这样的需求,那么我们可以在PrivacySupport模块生成一个aar包给其他项目接入使用,下面看看Privacy的代码:
package com.lcq.privacysupport; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.text.TextUtils; import android.widget.Toast; import java.util.Random; public class Privacy { public static final String SP_PRIVACY = "sp_privacy"; public static final String PRIVACY_AGREE = "privacy_agree"; private final Activity activity; public Privacy(Activity activity) { this.activity = activity; } public void check(final PrivacyCallback callback) { //通过SharedPreferences 获取是否已经同意了隐私政策协议 boolean agree = activity.getSharedPreferences(SP_PRIVACY, Context.MODE_PRIVATE).getBoolean(PRIVACY_AGREE, false); if (agree) {//之前已经同意则直接回调onAgree callback.onAgree(); return; } //接下来就是进行网络请求了,下面是本地构造的数据来模拟请求协议接口,返回数据 callPrivacyInfo(new ProtocolRequestCallback() { @Override public void onProtocolClose() { activity.getSharedPreferences(SP_PRIVACY, Context.MODE_PRIVATE).edit().putBoolean(PRIVACY_AGREE, true).apply(); callback.onAgree(); } @Override public void onCallbackProtocolResult(final ProtocolResult result) { //主线程启动隐私弹窗 activity.runOnUiThread(new Runnable() { @Override public void run() { new PrivacyDialog.Builder() .activity(activity) .protocolResult(result) .callback(callback) .build() .show(); } }); } @Override public void onFail(int code, String msg) { } @Override public String getProtocolType() { //返回弹窗首页的类型 return ProtocolInfo.TYPE_FIRST; } }, true); } /** * 展示协议界面 * * @param type 协议类型 */ public void showProtocol(final String type) { callPrivacyInfo(new ProtocolRequestCallback() { @Override public void onProtocolClose() { Toast.makeText(activity, "未打开协议开关", Toast.LENGTH_LONG).show(); } @Override public void onCallbackProtocolResult(ProtocolResult result) { if (result == null || TextUtils.isEmpty(result.getContent())) { Toast.makeText(activity, "无对应类型的协议:" + type, Toast.LENGTH_LONG).show(); return; } Intent intent = new Intent(activity, ProtocolActivity.class); intent.putExtra(ProtocolActivity.EXTRA_TITLE, result.getName()); intent.putExtra(ProtocolActivity.EXTRA_CONTENT, result.getContent()); activity.startActivity(intent); } @Override public void onFail(int code, String msg) { Toast.makeText(activity, "协议请求失败", Toast.LENGTH_LONG).show(); } @Override public String getProtocolType() { return type; } }, false); } /** * 请求协议信息 * * @param callback * @param failRetry 请求失败是否重复尝试 */ private void callPrivacyInfo(final ProtocolRequestCallback callback, final boolean failRetry) { //模拟获取协议信息 int response = new Random().nextInt(1);//模拟随机生成请求返回结果,随机bound设置为2以上可以模拟其他情况 switch (response) { case 0://模拟成功请求到协议信息 ProtocolResult protocolResult = ProtocolResult.obtain(callback.getProtocolType()); callback.onCallbackProtocolResult(protocolResult); break; case 1://模拟请求协议信息失败 if (failRetry) { handleFail(callback, "协议请求失败", true); } else { callback.onFail(response, "协议请求失败"); } break; case 2://模拟请求协议信息成功,但是协议开关关闭 callback.onProtocolClose(); break; default: callback.onFail(response, "未知错误"); } } private void handleFail(final ProtocolRequestCallback callback, final String msg, final boolean failRetry) { if (failRetry) { activity.runOnUiThread(new Runnable() { @Override public void run() { new AlertDialog.Builder(activity, android.R.style.Theme_DeviceDefault_Light_Dialog_Alert) .setTitle("请重试") .setMessage(msg) .setCancelable(false) .setNegativeButton("确认", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { callPrivacyInfo(callback, true); } }).show(); } }); } else { callback.onFail(-1, msg); } } }
-
Privacy的check方法具体实现如下:第一步肯定是检查用户是否已经同意过了,同意过了我们就直接回调onAgree,未同意过就调用callPrivacyInfo方法,failRetry用来处理失败后是否继续重新请求,不然请求失败后会卡屏的,ProtocolRequestCallback 的匿名内部类实现了几个方法:1、onProtocolClose 方法表示服务器关闭了隐私协议弹框,直接保存同意状态,回调onAgree,2、onCallbackProtocolResult 方法返回了协议请求的结果,这里我们直接构建协议弹窗PrivacyDialog,并设置ProtocolResult和callback,然后展示协议弹窗,3、getProtocolType方法返回弹窗首页的类型ProtocolInfo.TYPE_FIRST,这个类型需和服务器定义的保持一致
-
Privacy的showProtocol方法需要传递一个type过来,也就是上面提到的预定义类型user,policy,third,用来拉起对应的协议界面,这个方法直接callPrivacyInfo方法,failRetry传的false,不重复进行请求,ProtocolRequestCallback的onCallbackProtocolResult方法返回ProtocolResult,我们将name 和conten传递给ProtocolActivity,展示html格式的协议文本
-
PrivacyDialog是协议弹窗,代码如下:
package com.lcq.privacysupport; import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.text.Html; import android.text.SpannableString; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.view.Display; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; public class PrivacyDialog extends AlertDialog { private final Activity activity; private final PrivacyCallback callback; private final ProtocolResult protocolResult; private PrivacyDialog(Builder builder) { super(builder.activity); activity = builder.activity; protocolResult = builder.result; callback = builder.callback; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.dialog_privacy); setCancelable(false); getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); Button refuseBt = findViewById(R.id.refuse); Button agreeBtn = findViewById(R.id.agree); refuseBt.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //用户拒绝则关闭弹窗、关闭应用,你也可以弹出挽留的对话框,让用户继续选择是否退出 dismiss(); activity.finish(); //Process.killProcess(Process.myPid()); //System.exit(0); } }); agreeBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //用户同意则保存同意状态,下次启动检测这个状态不再弹隐私协议弹窗,并回调onAgree dismiss(); activity.getSharedPreferences(Privacy.SP_PRIVACY, Context.MODE_PRIVATE).edit() .putBoolean(Privacy.PRIVACY_AGREE, true) .apply(); if (callback != null) { callback.onAgree(); } } }); initContentTv(); } /** * 对弹窗里面的类容进行初始化, */ private void initContentTv() { TextView contentTv = findViewById(R.id.content); //将内容进行Html格式化后的CharSequence设置给TextView,并用来初始化SpannableString CharSequence htmlText = Html.fromHtml(protocolResult.getContent()); contentTv.setText(htmlText); SpannableString spannableString = new SpannableString(htmlText); for (final ProtocolInfo p : protocolResult.getProtocolList()) { setClickableSpan(spannableString, p); } contentTv.setMovementMethod(LinkMovementMethod.getInstance()); //点击事件才能起效 contentTv.setHighlightColor(Color.TRANSPARENT); contentTv.setText(spannableString); } /** * 设置点击文字的点击事件 * * @param spannableString * @param protocolInfo */ private void setClickableSpan(SpannableString spannableString, final ProtocolInfo protocolInfo) { String clickText = "《" + protocolInfo.getName() + "》";//被点击文字 String content = spannableString.toString(); int index = content.indexOf(clickText);//获取可点击文字的首次出现位置 while (index > 0) {//循环设置可点击文字的ClickableSpan,并处理点击事件 spannableString.setSpan(new ClickableSpan() { @Override public void onClick(View widget) { Toast.makeText(activity, "点击了" + clickText + ",跳转到" + protocolInfo.getType(), Toast.LENGTH_SHORT).show(); //模拟通过type请求服务器协议接口返回的结果,并将结果中的content和name传递给AgreementActivity ProtocolResult result = ProtocolResult.obtain(protocolInfo.getType()); Intent intent = new Intent(activity, ProtocolActivity.class); intent.putExtra(ProtocolActivity.EXTRA_CONTENT, result.getContent()); intent.putExtra(ProtocolActivity.EXTRA_TITLE, result.getName()); activity.startActivity(intent); } }, index, index + clickText.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); //获取下一个可点击文字的位置 index = content.indexOf(clickText, index + clickText.length()); } } @Override public void show() { super.show(); //横竖屏设置Dialog的不同的宽高比 Window window = this.getWindow(); WindowManager windowManager = activity.getWindowManager(); Display display = windowManager.getDefaultDisplay(); WindowManager.LayoutParams params = window.getAttributes(); if (activity.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) { params.width = (int) (display.getWidth() * 0.8); params.height = (int) (display.getHeight() * 0.6); } else { params.width = (int) (display.getHeight() * 0.8); params.height = (int) (display.getHeight() * 0.9); } window.setAttributes(params); window.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); } public static final class Builder { private Activity activity; private PrivacyCallback callback; private ProtocolResult result; public Builder() { } public Builder activity(Activity val) { activity = val; return this; } public Builder callback(PrivacyCallback val) { callback = val; return this; } public Builder protocolResult(ProtocolResult val) { result = val; return this; } public PrivacyDialog build() { return new PrivacyDialog(this); } } }
-
onCreate 里面初始化控件,拒绝按钮监听直接关闭弹窗,退出应用,同意按钮监听也关闭弹窗,并使用SharedPreferences保存同意状态,回调onAgree
-
initContentTv方法主要封装了协议类容的处理,我们需要将协议的内容protocolResult.getContent()使用Html.fromHtml()方法得到一个CharSequence,并设置给contentTv,再构建SpannableString对象,构造方法里面传入上面的 CharSequence 对象
-
协议弹框里面的特殊文本(比如《用户协议》)需要包含协议界面跳转逻辑,以及可点击的链接效果,为了让contentTv里面的协议文本响应点击事件,我们需要把服务器端传来的ProtocolList里面的每个ProtocolInfo与spannableString进行关联处理
扫描二维码关注公众号,回复: 16005856 查看本文章 -
setClickableSpan方法处理了关联逻辑,主要的思想就是构建可点击文字,然后取出spannableString里面的字符串,通过String的indexOf()方法定位到可点击文字的起始位置index,并调用spannableString的setSpan,将点击事件、index、index + clickText.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE传递给setSpan方法,ClickableSpan的onClick方法处理了点击文本跳转到对应类型的协议界面去,这样就完美的处理了富文本的展示与点击事件的处理
-
重写了PrivacyDialog的show方法来设置宽高比,并适配横竖屏
-
PrivacyDialog 定义的字段用Builder模式进行封装,方便外部链式调用
-
PrivacyDialog 的布局文件如下:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/root_splash_login" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" android:orientation="vertical" android:padding="10dp"> <LinearLayout android:id="@+id/ll" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_margin="10dp" android:gravity="center"> <Button android:id="@+id/refuse" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="拒绝" android:textSize="15sp" /> <Button android:id="@+id/agree" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginLeft="10dp" android:layout_weight="1" android:text="同意" android:textSize="15sp" /> </LinearLayout> <ScrollView android:layout_width="match_parent" android:layout_height="match_parent" android:layout_above="@id/ll"> <TextView android:id="@+id/content" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp" android:textColor="@android:color/black" android:textSize="16sp" /> </ScrollView> </RelativeLayout>
主要使用了TextView而不是WebView去加载html文本
-
ProtocolInfo 定义了协议的类型type以及协议的名称name,name主要用于首页定位可点击文本使用,type需要和服务器一起预定义,前端不具体化,网络请求的时候传递给服务器,服务器就会去拉对应的协议界面给前端,代码如下:
package com.lcq.privacysupport; public class ProtocolInfo { public static final String TYPE_FIRST = "first"; public static final String TYPE_USER = "user"; public static final String TYPE_POLICY = "policy"; public static final String TYPE_THIRD = "third"; private String type; private String name; public ProtocolInfo(String type, String name) { this.type = type; this.name = name; } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getName() { return name; } public void setName(String name) { this.name = name; } public static ProtocolInfo obtainFirst() { return new ProtocolInfo(TYPE_FIRST, "协议弹窗首页"); } public static ProtocolInfo obtainUser() { return new ProtocolInfo(TYPE_USER, "用户协议"); } public static ProtocolInfo obtainPolicy() { return new ProtocolInfo(TYPE_POLICY, "隐私政策"); } public static ProtocolInfo obtainThirdSDK() { return new ProtocolInfo(TYPE_THIRD, "第三方信息共享清单"); } }
-
PrivacyCallback接口的代码如下:
package com.lcq.privacysupport; public interface PrivacyCallback { void onAgree(); }
只定义了一个onAgree(),用于外部调用Privacy.check(PrivacyCallback callback)传入实现类
-
ProtocolRequestCallback接口的定义如下:
package com.lcq.privacysupport; public interface ProtocolRequestCallback { /** * 协议关闭的回调 */ void onProtocolClose(); /** * 回调协议结果 * * @param result 协议结果实体类 */ void onCallbackProtocolResult(ProtocolResult result); /** * 协议请求失败回调 * * @param code 错误码 * @param msg 错误信息 */ void onFail(int code, String msg); /** * 协议的类型,需和服务器协商预定义类型 * * @return */ String getProtocolType(); }
用于callPrivacyInfo(ProtocolRequestCallback callback,boolean failRetry)方法,调用方根据需求进行方法实现,也算是用到了一个策略模式吧
运行效果
- 启动页展示隐私政策弹窗:
- 用户同意后,进入主界面:
- 点击“展示协议界面”按钮展示用户协议界面:
总结
- 上面的代码实现还算是比较简单的,有些代码细节需要自己去阅读理解
- 这个方案可以运用到不同的项目中,不同项目可以自己定义协议内容和协议的类型,只需要多做几个Html界面就可以,注意Html的元素不能太复杂,TextView可支持的Html元素不是很多的,当然你也可以在本地app中读取html文件加载到TextView中,只不过每次的协议更新就必须得换包了,不能在线即时更新
- 这个实现方案相比WebView加载Html文件复杂很多,但是不会被渠道方打回来的,之前我们使用的WebView,结果应用在渠道检测没问题,上架后,却被点名通报隐私协议不合规,给定期限不整改就给你下架处理,现在这个方案经在各大渠道都检测没问题了,可以放心使用
- 如果有需要阅读项目源码的同学,可以看这里:https://gitee.com/lin-ciqiao/privacy-protocol