• 什么是AppOps
  • 涉及的类
  • 常用名词
  • 权限管理是如何触发的
  • 系统默认应用权限
  • AppOps Policy 文件
  • AppOpsService准备工作
  • 详细分析权限管理的触发
  • 应用层对权限的设置

什么是AppOps

Android App在AndroidManifest.xml中申请了很多运行时需要获取的权限,例如

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

用户在使用某个新安装的App的时候,经常会有弹窗弹出是否允许App获取某个权限,当确认获取后,用户才能正常使用该功能。而AppOps是framework提供给用户,确切点说是开发人员,更多操作应用权限的一个途径。用户可以对某个App的权限根据自己的需求进行禁止或者放行等。
在我理解,android系统中涉及App应用权限的主要涉及3个部分:

1. 系统默认的应用权限;
2. AppOps Policy文件;
3. 通过调用AppOpsManager中的接口对某一权限进行设置;

开发人员可以利用Policy文件和AppOpsManager中的接口对默认的应用权限进行修改。后续文章将会按照上面三部分进行展开。

涉及的类

以AppOps名字开始的类包括

Settings上层

packages/apps/Settings/src/com/android/settings/applications/AppOpsCategory.java
packages/apps/Settings/src/com/android/settings/applications/AppOpsDetails.java
packages/apps/Settings/src/com/android/settings/applications/AppOpsState.java
packages/apps/Settings/src/com/android/settings/applications/AppOpsSummary.java

frameworks/base/cmds/appops下面有个appops的应用程序

frameworks/native/libs/binder/AppOpsManager.cpp
frameworks/native/include/binder/AppOpsManager.h

Appops核心实现类,AppOpsService是功能具体实现,AppOpsManager是Service相关的Manager,是SDK中的一部分。AppOpsPolicy大概意思是,系统出厂时预制的系统App(System-app)和用户app(User-app)的一些默认的权限。非常有用,我们可以把我们一些预制的信任apk的权限都设置为允许,而不用老是弹窗提示。

frameworks/base/services/core/java/com/android/server/AppOpsService.java
frameworks/base/services/core/java/com/android/server/AppOpsPolicy.java
frameworks/base/core/java/android/app/AppOpsManager.java

常用名词

在framework中,
将某一权限称为Op,即operation,操作的意思。在AppOpsManager类中用以OP_开头的int表示具体权限,例如OP_COARSE_LOCATION,既表示coarse gps权限;
将某一权限的所对应的动作称为mode。在AppOpsManager类中用以MODE_开头的int表示动作,例如MODE_ALLOWED,表示允许相关权限的执行,即获取了相应的权限。

权限管理是如何触发的

当我们第一次在使用App的时候,访问某些系统权限时,例如访问wifi,都会弹出一个对话框询问用户是否允许访问wifi等。下面是WifiManager.java中打开WiFi的代码,

    public boolean setWifiEnabled(boolean enabled) {if (mAppOps.noteOp(AppOpsManager.OP_WIFI_CHANGE) !=AppOpsManager.MODE_ALLOWED)return false;try {return mService.setWifiEnabled(enabled);} catch (RemoteException e) {return false;}}

在打开wifi前,首先会去调用AppOpsManager中的noteOp函数,如果返回值不是允许,即MODE_ALLOWED,直接返回false,不允许打开wifi。从MODE_ALLOWED的注释我们就能看出,系统一般都是通过checkOp、noteOp、startOp去检测权限,但是使用的地方不同。关于noteOp,见后文。

/*** Result from {@link #checkOp}, {@link #noteOp}, {@link #startOp}: the given caller is* allowed to perform the given operation.*/
public static final int MODE_ALLOWED = 0;

系统默认应用权限

android系统中包含了strict模式和普通模式。什么是strict模式?
在AppOpsService类中有个变量,顾名思义,判断系统是否开启strict模式。

final boolean mStrictEnable;

mStrictEnable = AppOpsManager.isStrictEnable();
    public static boolean isStrictEnable() {return SystemProperties.getBoolean("persist.sys.strict_op_enable", false);}

可以看出,通过设置persist.sys.strict_op_enable为true,即开启了strict模式。那么strict模式和普通模式有什么差异?我们下面接着上一段(权限管理是如何触发的)分析。
调用–>

    public int noteOp(int op) {return noteOp(op, Process.myUid(), mContext.getOpPackageName());}

调用–>

    /*** Make note of an application performing an operation.  Note that you must pass* in both the uid and name of the application to be checked; this function will verify* that these two match, and if not, return {@link #MODE_IGNORED}.  If this call* succeeds, the last execution time of the operation for this app will be updated to* the current time.*/public int noteOp(int op, int uid, String packageName) {try {int mode = mService.noteOperation(op, uid, packageName);if (mode == MODE_ERRORED) {throw new SecurityException(buildSecurityExceptionMsg(op, uid, packageName));}
            return mode;} catch (RemoteException e) {}
        return MODE_IGNORED;}

从上面的注释我们也能证实我们前面的描述,noteOp主要是在一个app执行某个operation时需要note,即检验是否能够执行该操作,拥有该权限。函数中实际的实现还是调用了AppOps的实现类AppOpsService的noteOperation方法。
调用–>

  @Overridepublic int noteOperation(int code, int uid, String packageName) {//我们这里只看getOpLocked函数,其他省略Op op = getOpLocked(ops, code, true);if (isOpRestricted(uid, code, packageName)) {return AppOpsManager.MODE_IGNORED;}}

调用–>

    private Op getOpLocked(Ops ops, int code, boolean edit) {int mode;//如果该app是第一次使用,肯定返回空Op op = ops.get(code);if (op == null) {if (!edit) {return null;}mode = getDefaultMode(code, ops.uid, ops.packageName);op = new Op(ops.uid, ops.packageName, code, mode);ops.put(code, op);}if (edit) {scheduleWriteLocked();}return op;}

调用–>

   private int getDefaultMode(int code, int uid, String packageName) {int mode = AppOpsManager.opToDefaultMode(code,isStrict(code, uid, packageName));//首先检查某个op是否在strict模式下,mPolicy就是上面提到的policy文件//如果op在strict模式下,同时mPolicy存在,则从mPolicy中取默认权限if (AppOpsManager.isStrictOp(code) && mPolicy != null) {int policyMode = mPolicy.getDefualtMode(code, packageName);if (policyMode != AppOpsManager.MODE_ERRORED) {mode = policyMode;}}return mode;}
   public static int opToDefaultMode(int op, boolean isStrict) {//是strict模式,返回sOpDefaultStrictMode数组if (isStrict)return sOpDefaultStrictMode[op];//普通模式,返回sOpDefaultMode数组return sOpDefaultMode[op];}
    //mStrictEnable是enable的,同时是app,则返回trueprivate boolean isStrict(int code, int uid, String packageName) {if (!mStrictEnable)return false;return UserHandle.isApp(uid);}

从上面的分析,我们可以看出在strict模式下,op的对应默认mode在sOpDefaultStrictMode数组中查找,而普通模式下mode在sOpDefaultMode找。例如对于OP_COARSE_LOCATION,在strict模式下默认是询问AppOpsManager.MODE_ASK,而在普通模式下默认是AppOpsManager.MODE_ALLOWED。

AppOps Policy 文件

由上文可知,在获取某个op的默认权限时,如果在strict模式下,同时mPolicy不为null的时候,会去从mPolicy中读取权限。下面分析AppOps Policy 文件,
在AppOpsService中,有个默认的policy文件/system/etc/appops_policy.xml,

static final String DEFAULT_POLICY_FILE = "/system/etc/appops_policy.xml";

同时,在AppOpsService中有个AppOpsPolicy的instance,

AppOpsPolicy mPolicy;
    private void readPolicy() {if (mStrictEnable) {mPolicy = new AppOpsPolicy(new File(DEFAULT_POLICY_FILE), mContext);mPolicy.readPolicy();mPolicy.debugPoilcy();} else {mPolicy = null;}}

在strict模式下,实例化了一个AppOpsPolicy类,一个参数为默认的policy文件

   public AppOpsPolicy(File file, Context context) {super();mFile = file;mContext = context;}

调用–>

   void readPolicy() {FileInputStream stream;synchronized (mFile) {try {stream = new FileInputStream(mFile);} catch (FileNotFoundException e) {Slog.i(TAG, "App ops policy file (" + mFile.getPath()+ ") not found; Skipping.");return;String tagName = parser.getName();if (tagName.equals("user-app")|| tagName.equals("system-app")) {readDefaultPolicy(parser, tagName);} else if (tagName.equals("application")) {readApplicationPolicy(parser);} else {Slog.w(TAG, "Unknown element under <appops-policy>: "+ parser.getName());XmlUtils.skipCurrentTag(parser);}}

上面的函数就是读Policy文件的函数,,其实主要包括2个函数readDefaultPolicy(parser, tagName)和readApplicationPolicy(parser),我们也可以从函数中推测出policy文件的格式,起码包含user-app、system-app、application三个tag,user-app和system-app这两个tag对应的函数是readDefaultPolicy(parser, tagName),application tag对应的是readApplicationPolicy(parser),即

<appops-policy version="1"><user-app permission="ask" show="true"/><system-app permission="allowed" show="false"/><application><!-- Example:<pkg name="com.android.dialer" type="system-app"><op name="android:call_phone" permission="ask" show="true"/></pkg>--><pkg name="com.android.calendar" type="system-app"><op name="android:read_contacts" permission="ask" show="true"/></pkg><pkg name="com.android.email" type="system-app"><op name="android:read_contacts" permission="ask" show="true"/></pkg></application>
</appops-policy>

其中permission可以有allowed,ignored,ask和其他值,如下代码

   /** @hide */public static int stringToMode(String permission) {if ("allowed".equalsIgnoreCase(permission)) {return AppOpsManager.MODE_ALLOWED;} else if ("ignored".equalsIgnoreCase(permission)) {return AppOpsManager.MODE_IGNORED;} else if ("ask".equalsIgnoreCase(permission)) {return AppOpsManager.MODE_ASK;}return AppOpsManager.MODE_ERRORED;}

分别对应AppOpsManager.MODE_ALLOWED,AppOpsManager.MODE_IGNORED,AppOpsManager.MODE_ASK,其他字符串都会返回AppOpsManager.MODE_ERRORED。
而show表示是否将该权限show给用户,让用户去修改权限,如下所示,true和false分别对应CONTROL_SHOW,CONTROL_NOSHOW,如果为其他值,则对应CONTROL_UNKNOWN。

public static int stringToControl(String show) {if ("true".equalsIgnoreCase(show)) {return CONTROL_SHOW;} else if ("false".equalsIgnoreCase(show)) {return CONTROL_NOSHOW;}return CONTROL_UNKNOWN;
}

下面我们分析下推测出的AppOps Policy文件,首先在application标签外,有两个user-app,system-app的标签,分别的意思是:默认user-app(/data/app)的权限是,permission=”ask”,ask即AppOpsManager.MODE_ASK,意思是会有弹出框提示用户去点击是否允许该权限。show=”true”,允许用户去修改该权限,默认user-app的权限是true,允许修改(可以参考设置—安全—应用操作,点开某个app后,即可以修改权限是允许、提示、还是禁止)。同理,system-app(/system/app),permission=”allowed”,默认权限是允许,show=”false”,不允许用户操作。
以上两个是系统和用户app的默认权限,在application标签内,用户可以自定义添加自己app规则(如果没有自定义app规则,默认就是执行上面两个默认规则,如果有当前app自定义规则,则执行该规则,好理解吧),以搜狗输入法为例:

    <pkg name="com.sohu.inputmethod.sogou" type="user-app" permission="allowed" show="true"></pkg>

pkg标签包围, name=”com.sohu.inputmethod.sogou”,name是app的包名,type为user-app或者system-app,permission=”allowed”,所有申请的权限默认允许,show=”true”,同时指出用户去修改,show就是展示出来的意思。
通过添加上面的规则,你会发现,搜狗输入法再也不弹出位置这些权限的窗口让你来选择了。
在 AppOpsPolicy中有个成员,用来保存Policy文件所描述的这些规则,HashMap保存的格式为(packageName,PolicyPkg),每个App是其中的一项。

HashMap<String, PolicyPkg> mPolicy = new HashMap<String, PolicyPkg>();

其中PolicyPkg为AppOpsPolicy中的内部类,

//该内部类就是用来保存上面的 <op name="android:read_contacts" permission="ask" show="true"/>
public final static class PolicyOp {public int op;public int mode;public int show;public PolicyOp(int op, int mode, int show) {this.op = op;this.mode = mode;this.show = show;}@Overridepublic String toString() {return "PolicyOp [op=" + op + ", mode=" + mode + ", show=" + show+ "]";}}

public final static class PolicyPkg extends SparseArray<PolicyOp> {public String packageName;public int mode;public int show;public String type;public PolicyPkg(String packageName, int mode, int show, String type) {this.packageName = packageName;this.mode = mode;this.show = show;this.type = type;}@Overridepublic String toString() {return "PolicyPkg [packageName=" + packageName + ", mode=" + mode+ ", show=" + show + ", type=" + type + "]";}}

下面先介绍读取user-app和system-app这两个tag的函数,读取完成后,在mPolicy 中保存的格式为(“user-app”,PolicyPkg )或者(“system-app”,PolicyPkg ),

private void readDefaultPolicy(XmlPullParser parser, String packageName)throws NumberFormatException, XmlPullParserException, IOException {//必须是user-app或者system-appif (!"user-app".equalsIgnoreCase(packageName)&& !"system-app".equalsIgnoreCase(packageName)) {return;}int mode = AppOpsManager.stringToMode(parser.getAttributeValue(null,"permission"));int show = stringToControl(parser.getAttributeValue(null, "show"));if (mode == AppOpsManager.MODE_ERRORED && show == CONTROL_UNKNOWN) {return;}/*如果mPolicy没default policy,则新建一个PolicyPkg,一个hash键值对,这个键值对的格式是("user-app",PolicyPkg )或者("system-app",PolicyPkg ),如果已经存在,则修改mode和show*/PolicyPkg pkg = this.mPolicy.get(packageName);if (pkg == null) {pkg = new PolicyPkg(packageName, mode, show, packageName);this.mPolicy.put(packageName, pkg);} else {Slog.w(TAG, "Duplicate policy found for package: " + packageName+ " of type: " + packageName);pkg.mode = mode;pkg.show = show;}

接着是读取application tag下的元素,

 private void readApplicationPolicy(XmlPullParser parser)throws NumberFormatException, XmlPullParserException, IOException {....../*读取下面的pkg tag*/String tagName = parser.getName();if (tagName.equals("pkg")) {readPkgPolicy(parser);} else {Slog.w(TAG,"Unknown element under <application>: "+ parser.getName());XmlUtils.skipCurrentTag(parser);}}

调用–>
读取pkg本身的元素,如果pkg tag下还有op tag,继续读op tag。

 private void readPkgPolicy(XmlPullParser parser)throws NumberFormatException, XmlPullParserException, IOException {String packageName = parser.getAttributeValue(null, "name");if (packageName == null)return;String appType = parser.getAttributeValue(null, "type");if (appType == null)return;int mode = AppOpsManager.stringToMode(parser.getAttributeValue(null,"permission"));int show = stringToControl(parser.getAttributeValue(null, "show"));String key = packageName + "." + appType;PolicyPkg pkg = this.mPolicy.get(key);/*可以看到这里的键值对的键是以packageName + "." + appType形式,例如sogou输入法的键就为com.sohu.inputmethod.sogou.user-app,和default policy相同,如果mPolicy中没有该元素,则新建,否则修改mode和show。*/if (pkg == null) {pkg = new PolicyPkg(packageName, mode, show, appType);this.mPolicy.put(key, pkg);} else {Slog.w(TAG, "Duplicate policy found for package: " + packageName+ " of type: " + appType);pkg.mode = mode;pkg.show = show;}int outerDepth = parser.getDepth();int type;while ((type = parser.next()) != XmlPullParser.END_DOCUMENT&& (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {continue;}String tagName = parser.getName();if (tagName.equals("op")) {readOpPolicy(parser, pkg);} else {Slog.w(TAG, "Unknown element under <pkg>: " + parser.getName());XmlUtils.skipCurrentTag(parser);}}}

从上可以看出pkg下面的键值对形式为(“com.sohu.inputmethod.sogou.user-app”,PolicyPkg),下面接着读pkg下面的op tag。
调用–>
由于PolicyPkg extends SparseArray,所以PolicyPkg 内部有个保存pkg下相关op的一个SparseArray,保存的格式为(code,PolicyOp),code是个int,是稀疏数组的键吧。

private void readOpPolicy(XmlPullParser parser, PolicyPkg pkg)throws NumberFormatException, XmlPullParserException, IOException {if (pkg == null) {return;}String opName = parser.getAttributeValue(null, "name");if (opName == null) {Slog.w(TAG, "Op name is null");return;}int code = AppOpsManager.stringOpToOp(opName);if (code == AppOpsManager.OP_NONE) {Slog.w(TAG, "Unknown Op: " + opName);return;}int mode = AppOpsManager.stringToMode(parser.getAttributeValue(null,"permission"));int show = stringToControl(parser.getAttributeValue(null, "show"));if (mode == AppOpsManager.MODE_ERRORED && show == CONTROL_UNKNOWN) {return;}PolicyOp op = pkg.get(code);if (op == null) {op = new PolicyOp(code, mode, show);pkg.put(code, op);} else {Slog.w(TAG, "Duplicate policy found for package: "+ pkg.packageName + " type: " + pkg.type + " op: " + op.op);op.mode = mode;op.show = show;}}

至此,AppOps Policy文件已经读完。

AppOpsService准备工作

在AppOpsService类中,首先有个稀疏数组的成员mUidOps,顾名思义,涉及到Uid和Ops相关的东西。在android中,uid被赋予linux不同的任务,不同的App享有不同的Uid。作为稀疏数组的键,而对应的值为HashMap

final SparseArray<HashMap<String, Ops>> mUidOps= new SparseArray<HashMap<String, Ops>>();

Op是一个内部类,保存了一个op(权限)的详细信息,例如从什么时候开始运行的,等等。

public final static class Op {public final int uid;public final String packageName;public final int op;public int mode;public int duration;public long time;public long rejectTime;public int nesting;public int noteOpCount;public int startOpCount;public PermissionDialogReqQueue dialogReqQueue;final ArrayList<IBinder> clientTokens;public Op(int _uid, String _packageName, int _op, int _mode) {uid = _uid;packageName = _packageName;op = _op;mode = _mode;dialogReqQueue = new PermissionDialogReqQueue();clientTokens = new ArrayList<IBinder>();}}

而Ops也是一个内部类,包含了一个SparseArray,op的稀疏数组。Ops既是Op的复数,很多个Op的意思。

 public final static class Ops extends SparseArray<Op> {public final String packageName;public final int uid;public final boolean isPrivileged;public Ops(String _packageName, int _uid, boolean _isPrivileged) {packageName = _packageName;uid = _uid;isPrivileged = _isPrivileged;}}

所以,mUidOps保存的数据类似(uid,HashMap

详细分析权限管理的触发

前面讲过,在打开wifi时会调用AppOpsManager中的noteOp函数,进行权限的检测,下面详细分析,

public int noteOp(int op, int uid, String packageName) {try {int mode = mService.noteOperation(op, uid, packageName);if (mode == MODE_ERRORED) {throw new SecurityException(buildSecurityExceptionMsg(op, uid, packageName));}return mode;} catch (RemoteException e) {}return MODE_IGNORED;
}

调用–>
调用AppOpsService中的noteOperation,

public int noteOperation(int code, int uid, String packageName) {final PermissionDialogReq req;verifyIncomingUid(uid);verifyIncomingOp(code);synchronized (this) {//如果mUidOps中没有该App信息,则建立,否则返回OpsOps ops = getOpsLocked(uid, packageName, true);if (ops == null) {if (DEBUG) Log.d(TAG, "noteOperation: no op for code " + code + " uid " + uid+ " package " + packageName);return AppOpsManager.MODE_ERRORED;}//查找或者建立一个Op,放到ops中//此外在getOpLocked,参数edit为true的情况下回去写/data/system/appops.xml文件,如果这个//op是新建立的,则会马上更新appops.xml文件Op op = getOpLocked(ops, code, true);//至此,如果该app的这个权限是第一次在函数中处理,这时候已经在mUidOps和appops.xml中存在了if (isOpRestricted(uid, code, packageName)) {return AppOpsManager.MODE_IGNORED;}if (op.duration == -1) {Slog.w(TAG, "Noting op not finished: uid " + uid + " pkg " + packageName+ " code " + code + " time=" + op.time + " duration=" + op.duration);}op.duration = 0;/*将code转换为switchcode*/final int switchCode = AppOpsManager.opToSwitch(code);/*如果op和switchop不同*/final Op switchOp = switchCode != code ? getOpLocked(ops,switchCode, true) : op;/*如果mode不是allow和ask,则是被拒绝了,将reject的时间保存*/if (switchOp.mode != AppOpsManager.MODE_ALLOWED&& switchOp.mode != AppOpsManager.MODE_ASK) {if (DEBUG)Log.d(TAG, "noteOperation: reject #" + op.mode+ " for code " + switchCode + " (" + code+ ") uid " + uid + " package " + packageName);op.rejectTime = System.currentTimeMillis();return switchOp.mode;} else if (switchOp.mode == AppOpsManager.MODE_ALLOWED) {if (DEBUG)Log.d(TAG, "noteOperation: allowing code " + code + " uid "+ uid + " package " + packageName);/*如果mode是allow,记录note的时间,并将reject设置为0*/op.time = System.currentTimeMillis();op.rejectTime = 0;return AppOpsManager.MODE_ALLOWED;} else {if (Looper.myLooper() == mLooper) {Log.e(TAG,"noteOperation: This method will deadlock if called from the main thread. (Code: "+ code+ " uid: "+ uid+ " package: "+ packageName + ")");return switchOp.mode;}/*mode是ask,就是跳出来弹窗提醒,这里将op.noteOpCount增加*/op.noteOpCount++;req = askOperationLocked(code, uid, packageName, switchOp);}}return req.get();}

先看getOpsLocked函数

   private Ops getOpsLocked(int uid, String packageName, boolean edit) {/*uid为0,包名为root,uid为shell,包名为com.android.shell,uid为system,同时包名为空,则包名为android*/if (uid == 0) {packageName = "root";} else if (uid == Process.SHELL_UID) {packageName = "com.android.shell";} else if (uid == Process.SYSTEM_UID) {if (packageName == null)packageName = "android";}return getOpsRawLocked(uid, packageName, edit);}

接着调用,

/*有个参数edit,应该表示是不是可以修改mUidOps;返回一个Ops,如果mUidOps中还没有该app相关信息,则新建一个HashMap<String, Ops>,放到mUidOps*/
private Ops getOpsRawLocked(int uid, String packageName, boolean edit) {HashMap<String, Ops> pkgOps = mUidOps.get(uid);/*mUidOps里面没有这个uid的app*/if (pkgOps == null) {/*如果edit为false,不允许修改mUidOps,直接就返回了*/if (!edit) {return null;}/*如果没有这个uid的app,则新建一个,这时pkgOps还是空的*/pkgOps = new HashMap<String, Ops>();mUidOps.put(uid, pkgOps);}Ops ops = pkgOps.get(packageName);if (ops == null) {if (!edit) {return null;}boolean isPrivileged = false;// This is the first time we have seen this package name under this uid,// so let's make sure it is valid.//由上面的注释,主要是为了检验合法性if (uid != 0) {final long ident = Binder.clearCallingIdentity();try {int pkgUid = -1;try {ApplicationInfo appInfo = ActivityThread.getPackageManager().getApplicationInfo(packageName, 0, UserHandle.getUserId(uid));if (appInfo != null) {pkgUid = appInfo.uid;isPrivileged = (appInfo.flags & ApplicationInfo.FLAG_PRIVILEGED) != 0;} else {if ("media".equals(packageName)) {pkgUid = Process.MEDIA_UID;isPrivileged = false;}}} catch (RemoteException e) {Slog.w(TAG, "Could not contact PackageManager", e);}if (pkgUid != uid) {// Oops!  The package name is not valid for the uid they are calling// under.  Abort.Slog.w(TAG, "Bad call: specified package " + packageName+ " under uid " + uid + " but it is really " + pkgUid);return null;}} finally {Binder.restoreCallingIdentity(ident);}}//这里新建一个Ops,然后放到pkgOps中ops = new Ops(packageName, uid, isPrivileged);pkgOps.put(packageName, ops);}return ops;}

通过上面两步,如果mUidOps中还没有该app相关信息,则新建一个HashMap

private Op getOpLocked(Ops ops, int code, boolean edit) {int mode;Op op = ops.get(code);//如果这个App是第一次进入这个函数,那么op肯定是空的if (op == null) {if (!edit) {return null;}//获取默认的Mode,然后put到ops中mode = getDefaultMode(code, ops.uid, ops.packageName);op = new Op(ops.uid, ops.packageName, code, mode);ops.put(code, op);}//如果允许edit,则执行scheduleWriteLockedif (edit) {scheduleWriteLocked();}return op;
}

调用–>getDefaultMode
获取默认Mode前面已经讲过,这里如果这个op是strict的,同时mPolicy不为空,即Policy文件存在,则调用 mPolicy.getDefualtMode(code, packageName);

private int getDefaultMode(int code, int uid, String packageName) {int mode = AppOpsManager.opToDefaultMode(code,isStrict(code, uid, packageName));if (AppOpsManager.isStrictOp(code) && mPolicy != null) {int policyMode = mPolicy.getDefualtMode(code, packageName);//如果返回值是MODE_ERRORED,那么会返回上面的opToDefaultMode的Mode//感觉这种情况唯有mPolicy文件存在,但是没啥东西的时候,getDefualtMode返回MODE_ERROREDif (policyMode != AppOpsManager.MODE_ERRORED) {mode = policyMode;}}return mode;}

注释很明确,就是先从默认规则读相关的Mode,如果还有pkg,那么继续读pkg下面的能匹配的mode,如果有匹配的则会覆盖前面mode的值。mode的默认值是AppOpsManager.MODE_ERRORED。

public int getDefualtMode(int code, String packageName) {int mode = AppOpsManager.MODE_ERRORED;PolicyPkg pkg;String key;String type;if (mPolicy == null) {return mode;}if (DEBUG)Slog.d(TAG, "Default mode requested for op=" + code + " package="+ packageName);type = getAppType(packageName);if (type != null) {// Get value based on 'type'key = type;pkg = mPolicy.get(key);if (pkg != null && pkg.mode != AppOpsManager.MODE_ERRORED) {if (DEBUG)Slog.d(TAG, "Setting value based on type: " + pkg);mode = pkg.mode;}}// Get value based on 'pkg'.key = packageName;if (type != null) {key = key + "." + type;}pkg = mPolicy.get(key);if (pkg != null) {if (pkg.mode != AppOpsManager.MODE_ERRORED) {if (DEBUG)Slog.d(TAG, "Setting value based on packageName: " + pkg);mode = pkg.mode;}// Get value base on 'op'PolicyOp op = pkg.get(code);if (op != null) {if (op.mode != AppOpsManager.MODE_ERRORED) {if (DEBUG)Slog.d(TAG, "Setting value based on op: " + op);mode = op.mode;}}}if (DEBUG)Slog.d(TAG, "Returning mode=" + mode);return mode;}

在上面函数getOpLocked中最后会调用scheduleWriteLocked,会调用写线程去更新一个权限文件,

private void scheduleWriteLocked() {if (!mWriteScheduled) {mWriteScheduled = true;mHandler.postDelayed(mWriteRunner, WRITE_DELAY);}}

final Runnable mWriteRunner = new Runnable() {public void run() {synchronized (AppOpsService.this) {mWriteScheduled = false;mFastWriteScheduled = false;AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() {@Override protected Void doInBackground(Void... params) {//线程调用writeState写各个app相关权限的相关信息writeState();return null;}};task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[])null);}}
};

AppOpsService中有个成员mFile,是在构造函数中初始化的

 public AppOpsService(File storagePath, Handler handler) {mFile = new AtomicFile(storagePath);mHandler = handler;mLooper = Looper.myLooper();mStrictEnable = AppOpsManager.isStrictEnable();readState();}

而AppOpsService是在ActivityManagerService中实例化的,systemDir为/data/system,则上述需要更新的文件为/data/system/appops.xml

mAppOpsService = new AppOpsService(new File(systemDir, "appops.xml"), mHandler);

这个文件里面的内容,摘抄如下:

<app-ops>
<pkg n="com.android.providers.calendar">
<uid n="10001" p="true">
<op n="8" dm="0" t="1920100182" />
<op n="9" dm="0" t="1920062956" />
<op n="40" dm="0" t="1920100252" d="93" />
<op n="53" dm="0" t="1920062763" />
</uid>
</pkg>
<pkg n="com.android.dialer">
<uid n="10006" p="true">
<op n="6" dm="0" t="1920062244" />
<op n="53" dm="0" t="1920061743" />
</uid>
</pkg>
<pkg n="com.android.providers.media">
<uid n="10007" p="true">
<op n="23" dm="0" t="1920069787" />
<op n="40" dm="0" t="1920070834" d="97" />
<op n="53" dm="0" t="1920063053" />
</uid>
</pkg>
<pkg n="com.android.providers.downloads">
<uid n="10007" p="true">
<op n="53" dm="0" t="1920062960" />
</uid>
</pkg>
<pkg n="com.android.launcher">
<uid n="10009" p="true" />
</pkg>
<pkg n="com.android.managedprovisioning">
<uid n="10010" p="true">
<op n="53" dm="0" t="1920062971" />
</uid>
</pkg>
<pkg n="com.android.mms">
<uid n="10011" p="true">
<op n="14" dm="0" t="1920079185" />
<op n="15" dm="0" t="1920079178" />
<op n="40" dm="0" t="1920079204" d="46" />
<op n="51" dm="0" t="1920064837" />
<op n="53" dm="0" t="1920064530" />
<op n="56" dm="0" t="1920076500" />
</uid>
</pkg>
<pkg n="com.android.onetimeinitializer">
<uid n="10013" p="true">
<op n="53" dm="0" t="1920064547" />
</uid>
</pkg>
<pkg n="com.android.systemui">
<uid n="10016" p="true">
<op n="4" dm="0" t="1920057614" />
<op n="23" dm="0" t="1920057673" />
<op n="40" dm="0" t="1921212577" d="131" />
<op n="53" dm="0" t="1920060458" />
</uid>
</pkg>
<pkg n="com.cyanogenmod.trebuchet">
<uid n="10018" p="true">
<op n="3" dm="0" t="1921174279" d="66" />
<op n="53" dm="0" t="1920064754" />
</uid>
</pkg>
<pkg n="com.android.calendar">
<uid n="10025" p="false">
<op n="8" dm="0" t="1920085515" />
<op n="9" dm="0" t="1920095150" />
<op n="40" dm="0" t="1920085526" d="10112" />
<op n="53" dm="0" t="1920065067" />
</uid>
</pkg>
<pkg n="com.qualcomm.qti.calendarlocalaccount">
<uid n="10026" p="false">
<op n="53" dm="0" t="1920065089" />
</uid>
</pkg>
<pkg n="com.qualcomm.qti.calendarwidget">
<uid n="10027" p="false">
<op n="8" dm="0" t="1920078119" />
</uid>
</pkg>
<pkg n="com.android.camera2">
<uid n="10028" p="false">
<op n="53" dm="0" t="1920065263" />
</uid>
</pkg>
<pkg n="com.qti.cbwidget">
<uid n="10031" p="false" />
</pkg>
<pkg n="com.android.deskclock">
<uid n="10033" p="false">
<op n="40" dm="0" t="1920061737" d="155" />
<op n="53" dm="0" t="1920061562" />
</uid>
</pkg>
<pkg n="com.android.email">
<uid n="10035" p="false">
<op n="53" dm="0" t="1920066104" />
</uid>
</pkg>
<pkg n="com.android.gallery3d">
<uid n="10042" p="false">
<op n="23" dm="0" t="1920067518" />
<op n="53" dm="0" t="1920067465" />
</uid>
</pkg>
<pkg n="com.qcom.gsma.services.nfc">
<uid n="10043" p="false">
<op n="53" dm="0" t="1920067652" />
</uid>
</pkg>
<pkg n="com.android.inputmethod.latin">
<uid n="10047" p="false">
<op n="53" dm="0" t="1920067800" />
</uid>
</pkg>
<pkg n="com.android.music">
<uid n="10051" p="false" />
</pkg>
<pkg n="com.android.provision">
<uid n="10063" p="false">
<op n="23" dm="0" t="1920056281" />
</uid>
</pkg>
<pkg n="com.qualcomm.atuner">
<uid n="10073" p="false">
<op n="53" dm="0" t="1920072364" />
</uid>
</pkg>
<pkg n="com.qualcomm.embms">
<uid n="10074" p="false">
<op n="53" dm="0" t="1920072617" />
</uid>
</pkg>
<pkg n="com.borqs.videocall">
<uid n="10078" p="false">
<op n="53" dm="0" t="1920073353" />
</uid>
</pkg>
</app-ops>

其中app-ops tag包含着很多个pkg tag,pkg tag中又包含了很多个op的tag,各个元素的意义见后文分析,例如

<pkg n="com.android.providers.calendar">
<uid n="10001" p="true">
<op n="8" dm="0" t="1920100182" />
<op n="9" dm="0" t="1920062956" />
<op n="40" dm="0" t="1920100252" d="93" />
<op n="53" dm="0" t="1920062763" />
</uid>
</pkg>

下面我们看writeState函数,这个函数就说明了如何去写/data/system/appops.xml文件。

void writeState() {synchronized (mFile) {/*调用getPackagesForOps函数,入参为null,即要获取mUidOps所有的数据;而appops.xml这个文件其实就是mUidOps所有数据的体现。*/List<AppOpsManager.PackageOps> allOps = getPackagesForOps(null);FileOutputStream stream;try {stream = mFile.startWrite();} catch (IOException e) {Slog.w(TAG, "Failed to write state: " + e);return;}try {XmlSerializer out = new FastXmlSerializer();out.setOutput(stream, "utf-8");out.startDocument(null, true);//写app-ops tagout.startTag(null, "app-ops");if (allOps != null) {String lastPkg = null;for (int i=0; i<allOps.size(); i++) {AppOpsManager.PackageOps pkg = allOps.get(i);if (!pkg.getPackageName().equals(lastPkg)) {if (lastPkg != null) {out.endTag(null, "pkg");}//<pkg n="com.android.providers.calendar">lastPkg = pkg.getPackageName();out.startTag(null, "pkg");out.attribute(null, "n", lastPkg);}//<uid n="10001" p="true">out.startTag(null, "uid");out.attribute(null, "n", Integer.toString(pkg.getUid()));//这个isPrivileged,还不清楚具体意思synchronized (this) {Ops ops = getOpsLocked(pkg.getUid(), pkg.getPackageName(), false);// Should always be present as the list of PackageOps is generated// from Ops.if (ops != null) {out.attribute(null, "p", Boolean.toString(ops.isPrivileged));} else {out.attribute(null, "p", Boolean.toString(false));}}List<AppOpsManager.OpEntry> ops = pkg.getOps();//下面就是一个个<op n="40" dm="0" t="1920100252" d="93" />//m的意思是该op和defaultmode不同,例如通过上册修改过该op的权限//dm就是依然是defaultmodefor (int j=0; j<ops.size(); j++) {AppOpsManager.OpEntry op = ops.get(j);out.startTag(null, "op");out.attribute(null, "n", Integer.toString(op.getOp()));int defaultMode = getDefaultMode(op.getOp(),pkg.getUid(), pkg.getPackageName());if (op.getMode() != defaultMode) {out.attribute(null, "m", Integer.toString(op.getMode()));} else {out.attribute(null, "dm", Integer.toString(defaultMode));}long time = op.getTime();//t是op的时间if (time != 0) {out.attribute(null, "t", Long.toString(time));}time = op.getRejectTime();if (time != 0) {out.attribute(null, "r", Long.toString(time));}int dur = op.getDuration();if (dur != 0) {out.attribute(null, "d", Integer.toString(dur));}out.endTag(null, "op");}out.endTag(null, "uid");}if (lastPkg != null) {out.endTag(null, "pkg");}}out.endTag(null, "app-ops");out.endDocument();mFile.finishWrite(stream);} catch (IOException e) {Slog.w(TAG, "Failed to write state, restoring backup.", e);mFile.failWrite(stream);}}}

首先调用getPackagesForOps函数,

/*通过ops,即权限,找对应的package,也就是拥有这些op的package;PackageOps是AppOpsManager中的一个类,注释很明确,Class holding all of the operation information associated with an app;如果参数ops为空,则表示要获取mUidOps中的所有数据;
*/
public List<AppOpsManager.PackageOps> getPackagesForOps(int[] ops) {mContext.enforcePermission(android.Manifest.permission.GET_APP_OPS_STATS,Binder.getCallingPid(), Binder.getCallingUid(), null);ArrayList<AppOpsManager.PackageOps> res = null;//从mUidOps中查找,循环mUidOps所有的数据synchronized (this) {for (int i=0; i<mUidOps.size(); i++) {HashMap<String, Ops> packages = mUidOps.valueAt(i);for (Ops pkgOps : packages.values()) {/*这里调用collectOps,见下;而OpEntry,Class holding the information about one unique operation of an  application,既是一个op;*/ArrayList<AppOpsManager.OpEntry> resOps = collectOps(pkgOps, ops);if (resOps != null) {if (res == null) {res = new ArrayList<AppOpsManager.PackageOps>();}AppOpsManager.PackageOps resPackage = new AppOpsManager.PackageOps(pkgOps.packageName, pkgOps.uid, resOps);res.add(resPackage);}}}}return res;}
/*如果ops为空,就表示需要获取所有的op;而如果ops不为空,则需要通过键值对匹配,pkgOps.get(ops[j]);
*/
private ArrayList<AppOpsManager.OpEntry> collectOps(Ops pkgOps, int[] ops) {ArrayList<AppOpsManager.OpEntry> resOps = null;if (ops == null) {resOps = new ArrayList<AppOpsManager.OpEntry>();for (int j=0; j<pkgOps.size(); j++) {Op curOp = pkgOps.valueAt(j);resOps.add(new AppOpsManager.OpEntry(curOp.op, curOp.mode, curOp.time,curOp.rejectTime, curOp.duration));}} else {for (int j=0; j<ops.length; j++) {Op curOp = pkgOps.get(ops[j]);if (curOp != null) {if (resOps == null) {resOps = new ArrayList<AppOpsManager.OpEntry>();}resOps.add(new AppOpsManager.OpEntry(curOp.op, curOp.mode, curOp.time,curOp.rejectTime, curOp.duration));}}}return resOps;}

说完writeState函数,顺便看下readState函数,即从/data/system/appops.xml中读取信息,更新mUidOps。

void readState() {.......... if (tagName.equals("pkg")) {readPackage(parser);} else {Slog.w(TAG, "Unknown element under <app-ops>: "+ parser.getName());XmlUtils.skipCurrentTag(parser);}}success = true;}

调用

void readPackage(XmlPullParser parser) throws NumberFormatException,XmlPullParserException, IOException {String pkgName = parser.getAttributeValue(null, "n");int outerDepth = parser.getDepth();int type;while ((type = parser.next()) != XmlPullParser.END_DOCUMENT&& (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {continue;}String tagName = parser.getName();if (tagName.equals("uid")) {readUid(parser, pkgName);} else {Slog.w(TAG, "Unknown element under <pkg>: "+ parser.getName());XmlUtils.skipCurrentTag(parser);}}}

调用readUid,

 void readUid(XmlPullParser parser, String pkgName) throws NumberFormatException,XmlPullParserException, IOException {int uid = Integer.parseInt(parser.getAttributeValue(null, "n"));String isPrivilegedString = parser.getAttributeValue(null, "p");boolean isPrivileged = false;//是否拥有Privileged属性if (isPrivilegedString == null) {try {IPackageManager packageManager = ActivityThread.getPackageManager();if (packageManager != null) {ApplicationInfo appInfo = ActivityThread.getPackageManager().getApplicationInfo(pkgName, 0, UserHandle.getUserId(uid));if (appInfo != null) {isPrivileged = (appInfo.flags & ApplicationInfo.FLAG_PRIVILEGED) != 0;}} else {// Could not load data, don't add to cache so it will be loaded later.return;}} catch (RemoteException e) {Slog.w(TAG, "Could not contact PackageManager", e);}} else {isPrivileged = Boolean.parseBoolean(isPrivilegedString);}int outerDepth = parser.getDepth();int type;while ((type = parser.next()) != XmlPullParser.END_DOCUMENT&& (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {continue;}//下面就是具体的写相关tag和attribute什么的String tagName = parser.getName();if (tagName.equals("op")) {int code = Integer.parseInt(parser.getAttributeValue(null, "n"));Op op = new Op(uid, pkgName, code, AppOpsManager.MODE_ERRORED);String mode = parser.getAttributeValue(null, "m");if (mode != null) {op.mode = Integer.parseInt(mode);} else {String sDefualtMode = parser.getAttributeValue(null, "dm");int defaultMode;if (sDefualtMode != null) {defaultMode = Integer.parseInt(sDefualtMode);} else {defaultMode = getDefaultMode(code, uid, pkgName);}op.mode = defaultMode;}String time = parser.getAttributeValue(null, "t");if (time != null) {op.time = Long.parseLong(time);}time = parser.getAttributeValue(null, "r");if (time != null) {op.rejectTime = Long.parseLong(time);}String dur = parser.getAttributeValue(null, "d");if (dur != null) {op.duration = Integer.parseInt(dur);}HashMap<String, Ops> pkgOps = mUidOps.get(uid);if (pkgOps == null) {pkgOps = new HashMap<String, Ops>();mUidOps.put(uid, pkgOps);}Ops ops = pkgOps.get(pkgName);if (ops == null) {ops = new Ops(pkgName, uid, isPrivileged);pkgOps.put(pkgName, ops);}ops.put(op.op, op);} else {Slog.w(TAG, "Unknown element under <pkg>: "+ parser.getName());XmlUtils.skipCurrentTag(parser);}}}

下面继续分析noteOperation函数,接着调用,

    final int switchCode = AppOpsManager.opToSwitch(code);
   public static int opToSwitch(int op) {return sOpToSwitch[op];}

这里的sOpToSwitch[]数组的解释如下

    /*** This maps each operation to the operation that serves as the* switch to determine whether it is allowed.  Generally this is* a 1:1 mapping, but for some things (like location) that have* multiple low-level operations being tracked that should be* presented to the user as one switch then this can be used to* make them all controlled by the same single operation.*/private static int[] sOpToSwitch = new int[] {OP_COARSE_LOCATION,OP_COARSE_LOCATION,OP_COARSE_LOCATION,.................}

应用层对权限的设置

上面两节已经介绍了App默认权限以及Policy文件的读取,下面将介绍最后一部分,也是最关键的,上层应用调用AppOpsManager的接口对权限进行设置,将会使用到App默认权限和Policy文件部分的内容。
以原生系统setting中AppOpsDetails为例,都是调用AppOpsManager的setMode函数对权限进行设置,

 private boolean refreshUi() {.......@Overridepublic void onItemSelected(AdapterView<?> parentView, View selectedItemView, int position, long id) {if(firstMode) {firstMode = false;return;}mAppOps.setMode(switchOp, entry.getPackageOps().getUid(),entry.getPackageOps().getPackageName(), positionToMode(position));}@Overridepublic void onNothingSelected(AdapterView<?> parentView) {// your code here}});sw.setChecked(mAppOps.checkOp(switchOp, entry.getPackageOps().getUid(), entry.getPackageOps().getPackageName()) == AppOpsManager.MODE_ALLOWED);sw.setOnCheckedChangeListener(new Switch.OnCheckedChangeListener() {public void onCheckedChanged(CompoundButton buttonView,boolean isChecked) {mAppOps.setMode(switchOp, entry.getPackageOps().getUid(), entry.getPackageOps().getPackageName(),isChecked ? AppOpsManager.MODE_ALLOWED: AppOpsManager.MODE_IGNORED);}});if (AppOpsManager.isStrictOp(switchOp)) {sp.setVisibility(View.VISIBLE);} else {sw.setVisibility(View.VISIBLE);}}}return true;}

而AppOpsManager又调用service中的setMode,修改mUidOps中相关权限信息,

  public void setMode(int code, int uid, String packageName, int mode) {try {mService.setMode(code, uid, packageName, mode);} catch (RemoteException e) {}}

即,

public void setMode(int code, int uid, String packageName, int mode) {if (Binder.getCallingPid() != Process.myPid()) {mContext.enforcePermission(android.Manifest.permission.UPDATE_APP_OPS_STATS,Binder.getCallingPid(), Binder.getCallingUid(), null);}verifyIncomingOp(code);ArrayList<Callback> repCbs = null;code = AppOpsManager.opToSwitch(code);synchronized (this) {Op op = getOpLocked(code, uid, packageName, true);if (op != null) {if (op.mode != mode) {/*这里设置mode*/op.mode = mode;ArrayList<Callback> cbs = mOpModeWatchers.get(code);if (cbs != null) {if (repCbs == null) {repCbs = new ArrayList<Callback>();}repCbs.addAll(cbs);}cbs = mPackageModeWatchers.get(packageName);if (cbs != null) {if (repCbs == null) {repCbs = new ArrayList<Callback>();}repCbs.addAll(cbs);}if (mode == getDefaultMode(code, uid, packageName)) {// If going into the default mode, prune this op// if there is nothing else interesting in it./*如果这个mode是默认的mode,则在mUidOps中将该op删除*/pruneOp(op, uid, packageName);}scheduleFastWriteLocked();}}}if (repCbs != null) {for (int i=0; i<repCbs.size(); i++) {try {repCbs.get(i).mCallback.opChanged(code, packageName);} catch (RemoteException e) {}}}}

Android 5.1 AppOps总结相关推荐

  1. Android 6.0 AppOps 简介

    --------------------- 从Android M开始,Google就正式推出了官方的权限管理机制Android Runtime Permission. AppOps终究没有走到台面^^ ...

  2. Android原生权限管理:AppOps

    --------------------- 从Android M开始,Google就正式推出了官方的权限管理机制Android Runtime Permission. AppOps终究没有走到台面^^ ...

  3. Android之内核学习笔记

    0.Android系统启动 <Android系统启动流程 -- bootloader> <The Android boot process from power on> < ...

  4. Android内核学习笔记

    0.android系统启动 <Android系统启动流程 -- bootloader> <The Android boot process from power on> < ...

  5. Android权限管理原理(含6.0)

    前言 Android系统在MarshMallow之前,权限都是在安装的时候授予的,虽然在4.3时,Google就试图在源码里面引入AppOpsManager来达到动态控制权限的目的,但由于不太成熟,在 ...

  6. android_ADB 调试工具总结

    Android ADB 调试工具总结 ADB,即 Android Debug Bridge Android 调试桥 (adb) 是一个通用命令行工具,其允许您与模拟器实例或连接的 Android 设备 ...

  7. android6悬浮窗权限设置,适配悬浮窗权限与系统设置修改权限

    Android 6.0的特殊权限(Special Permissions)包括java 悬浮窗权限(SYSTEM_ALERT_WINDOW) 系统设置修改权限(WRITE_SETTINGS) Andr ...

  8. Android S关闭定位开关后,定位权限被AppOps限制。

    一.背景 在Android S上,有一个新增的设计--定位开关被关闭以后,应用申请的定位权限OP_FINE_LOCATION和OP_COARSE_LOCATION会被系统AppOps限制. 二.问题 ...

  9. Android权限 - AppOps介绍

    1.介绍 frameworks\base\core\java\android\app\AppOpsManager.java frameworks\base\services\core\java\com ...

最新文章

  1. jQuery 事件绑定
  2. PTA数组作业一查找整数
  3. 曹大带我学 Go(9)—— 开始积累自己的工具库
  4. 跟计算机断层扫描相关的技术,计算机断层扫描技术(简称PET)
  5. 计算机维修与维护入门,计算机组装与维护基础知识
  6. UNIX(多线程):23---线程池注意事项和常见问题
  7. 台式电脑可以连wifi吗_不使用WIFI,手机也可以通过电脑的网络上网
  8. python 贴吧自动发帖_python接口自动化3-自动发帖(session)
  9. Unreal Engine 4 —— Ghost Mesh Plugin的开发日志
  10. (51)多路时钟复用概述
  11. Windows Server 2012 搭建DHCP及远程路由访问
  12. 第一堂TCP/IP课
  13. 内部收益率计算公式用计算机,使用EXCEL函数公式计算内部收益率,请请人解决,非常感谢! ! !...
  14. 哈工大CSAPP程序人生大作业
  15. 来电弹屏功能在呼叫中心的应用
  16. ZynqMP 调试 FSBL 代码
  17. Scala中List的步长by
  18. 2018.12.6 python基础学习——列表的魔法(一)
  19. 天地图加载慢 快速打开天地图
  20. JAVA基础 练习--运动员和教练

热门文章

  1. QNX铁路安全软件--QNX在铁路系统领域应用
  2. 苹果12轻点背面不灵敏如何解决
  3. 开源库3dTagCloudAndroid使用,实现3D球形云标签tag效果
  4. 服务器装机选哪个系统好,服务器该装08系统好还是03系统好?
  5. MATLAB与数学建模:初阶绘图
  6. 阿里云OSS对象存储-图文详解
  7. 热烈庆祝女朋友的生日
  8. 3.1 人工智能定义
  9. 小米路由器3G(R3G)刷潘多拉
  10. AUTOEXEC.BAT及CONFIG.SYS文件用法