Using R: iOS App 背着我们干了啥?

最近随着 iOS 15 中「记录 App 活动」功能的加入,以微信为代表的一类软件频繁读写用户信息的行为被抓了现形。具体的新闻可以阅读 Hackl0us 发布在知乎的记录.

虽然微信在 10 月 11 日发布了新版似乎修复了这个问题,然而美团针对每 5 分钟获取一次用户定位的行为发布了公告:

美团 App 技术工程师就此前美团 APP ”频繁定位“回应:之所以出现这种情况,是因为这类软件在单方面读取系统操作日志后,进行了选择性展示,经测试,在相关权限开启且 App 后台仍处于活跃状态时,大部分主流 App 均会被该软件检测出频繁读取用户信息,且监测结果高度相似。

该工程师还表示,并未对上述读取 iOS 15 系统日志的软件进行安全性和保密性测试,建议大家谨慎下载。

hmmm,怎么说呢,就无话可说吧,既然有可能是这类检测软件的问题,那么我就排除软件的障碍,自己手动检测试试看。

首先在 iOS 15 设备进入「设置」-「隐私」–「记录 App 活动」,打开 App 活动开关,等待一定时间,iOS 会自动记录期间所有 App 活动,点击存储 App 活动即可导出为 ndjson 文件——声明:本操作系 iOS 自主记录日志,且用户绝对有权力单方面导出,此操作不涉及也没有办法选择性导出,更不具备展示功能。

这里的 ndjson 大致相当于一种流式 json 文件,可以通过 ndjson 包读取为 data.table 并转换为 tibble。

读取与预览

首先是读取 ndjson 文件,并预览

声明:下列读取 iOS 15 系统日志的操作并未进行安全性和保密性测试,建议大家谨慎操作,或者拔掉网线并开启电磁屏蔽操作(摊手。

library("ndjson")
library("pillar")
library("dplyr")
## 
## Attaching package: 'dplyr'
## The following object is masked from 'package:pillar':
## 
##     dim_desc
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library("lubridate")
## 
## Attaching package: 'lubridate'
## The following objects are masked from 'package:base':
## 
##     date, intersect, setdiff, union
app_privacy_report_tb <-
  ndjson::stream_in("resource/App_Privacy_Report_v4_2021-10-11T22_36_54.ndjson",
                    cls = "tbl")

glimpse(app_privacy_report_tb)
## Rows: 19,687
## Columns: 15
## $ accessor.identifier     <chr> "com.xiaomi.mihome", "com.xiaomi.mihome", "com…
## $ accessor.identifierType <chr> "bundleID", "bundleID", "bundleID", "bundleID"…
## $ category                <chr> "location", "location", "location", "location"…
## $ identifier              <chr> "60E8004B-D969-4ABB-B83F-460663BCC29F", "60E80…
## $ kind                    <chr> "intervalBegin", "intervalEnd", "intervalBegin…
## $ timeStamp               <chr> "2021-10-08T13:30:43.340+08:00", "2021-10-08T1…
## $ type                    <chr> "access", "access", "access", "access", "acces…
## $ bundleID                <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ context                 <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ domain                  <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ domainOwner             <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ domainType              <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ firstTimeStamp          <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ hits                    <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ initiatedType           <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
head(app_privacy_report_tb)
## # A tibble: 6 × 15
##   accessor.identif… accessor.identif… category identifier  kind  timeStamp type 
##   <chr>             <chr>             <chr>    <chr>       <chr> <chr>     <chr>
## 1 com.xiaomi.mihome bundleID          location 60E8004B-D… inte… 2021-10-… acce…
## 2 com.xiaomi.mihome bundleID          location 60E8004B-D… inte… 2021-10-… acce…
## 3 com.xiaomi.mihome bundleID          location 865B785C-D… inte… 2021-10-… acce…
## 4 com.xiaomi.mihome bundleID          location 865B785C-D… inte… 2021-10-… acce…
## 5 com.xiaomi.mihome bundleID          location 7626338D-D… inte… 2021-10-… acce…
## 6 com.xiaomi.mihome bundleID          location 7626338D-D… inte… 2021-10-… acce…
## # … with 8 more variables: bundleID <chr>, context <chr>, domain <chr>,
## #   domainOwner <chr>, domainType <dbl>, firstTimeStamp <chr>, hits <dbl>,
## #   initiatedType <chr>

三天记录下所有 App 总共有 19687 条隐私请求,不过由于记录是成对出现的,即开始请求——请求结束,所以说请求次数只有一半9843.5呃,怎么说呢,就还挺勤劳的吧。

我们首先关注 accessor.identifier (App ID), category (访问分类), kind(时间戳类型),timeStamp (时间戳), type (大类) 这几列,选择上述列并设置为合适的数据类型。

main_tb <-
app_privacy_report_tb %>% 
  select(accessor.identifier, category, kind, type, timeStamp) %>% 
  mutate(
    `accessor.identifier` = as.factor(accessor.identifier),
    category = as.factor(category),
    kind = as.factor(kind),
    type = as.factor(type),
    timeStamp = ymd_hms(timeStamp)
  )

head(main_tb)
## # A tibble: 6 × 5
##   accessor.identifier category kind          type   timeStamp          
##   <fct>               <fct>    <fct>         <fct>  <dttm>             
## 1 com.xiaomi.mihome   location intervalBegin access 2021-10-08 05:30:43
## 2 com.xiaomi.mihome   location intervalEnd   access 2021-10-08 05:34:21
## 3 com.xiaomi.mihome   location intervalBegin access 2021-10-08 05:34:24
## 4 com.xiaomi.mihome   location intervalEnd   access 2021-10-08 05:34:53
## 5 com.xiaomi.mihome   location intervalBegin access 2021-10-08 05:34:58
## 6 com.xiaomi.mihome   location intervalEnd   access 2021-10-08 05:35:08
skimr::skim(main_tb)
(#tab:tb_select_type_convert)Data summary
Name main_tb
Number of rows 19687
Number of columns 5
_______________________
Column type frequency:
factor 4
POSIXct 1
________________________
Group variables None

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
accessor.identifier 9121 0.54 FALSE 26 com: 9062, com: 236, com: 228, com: 176
category 9121 0.54 FALSE 4 loc: 9940, con: 290, pho: 288, cam: 48
kind 9121 0.54 FALSE 2 int: 5283, int: 5283
type 0 1.00 FALSE 2 acc: 10566, net: 9121

Variable type: POSIXct

skim_variable n_missing complete_rate min max median n_unique
timeStamp 0 1 2021-10-08 05:30:41 2021-10-11 14:36:29 2021-10-09 02:33:52 19671

在这三天里面,总共有 26 款 App 请求了我的隐私数据(共安装 130 款 App),那么平均下来一款 App 就请求了 378.5961538 次,然而这怎么可能嘛!必然是有更勤劳的小蜜蜂。

数据可视化

library(forcats)
library(ggplot2)

main_tb %>%
  group_by(accessor.identifier) %>% 
  count() %>% 
  ungroup() %>% 
  mutate(
    accessor.identifier = fct_reorder(accessor.identifier, n)
  ) %>% 
  ggplot(aes(
    x = accessor.identifier,
    y = n
  )) +
  geom_col() +
  coord_flip()

简单的排序后发现三个有趣的点:

  1. 在 accessor.identifier 中 com.dianping (美团点评)并不是非常显眼,这可能是因为我已经关闭了大众点评和美团等 App 的后台定位权限相关;
  2. com.xiaomi.mihome 这位小老弟还是挺疯狂,盲猜是因为我开启了它的后台定位所致,然而我并没有开启地理围栏等相关智能化脚本,这个稍后再看;
  3. NA 太多了,也就是说并非所有的操作都有 accessor.identifier?后续再看。

根据 main_tb 的预览, 与 App 身份认证相关的列还有 accessor.identifierType 以及 identifier

app_privacy_report_tb %>% 
  select(accessor.identifier, 
         accessor.identifierType) %>% 
  mutate(across(everything(), as.factor)) %>%
  unique() %>% 
  print(., n = nrow(.))
## # A tibble: 27 × 2
##    accessor.identifier               accessor.identifierType
##    <fct>                             <fct>                  
##  1 com.xiaomi.mihome                 bundleID               
##  2 com.TickTick.task                 bundleID               
##  3 com.autonavi.amap                 bundleID               
##  4 io.robbie.HomeAssistant           bundleID               
##  5 com.google.photos                 bundleID               
##  6 com.cainiao.cnwireless            bundleID               
##  7 com.taobao.taobao4iphone          bundleID               
##  8 com.lifubing.lbs.stepOfLife       bundleID               
##  9 com.tencent.xin                   bundleID               
## 10 ph.telegra.Telegraph              bundleID               
## 11 com.taobao.fleamarket             bundleID               
## 12 com.readdle.smartemail            bundleID               
## 13 com.tencent.tim                   bundleID               
## 14 com.360buy.jdmobile               bundleID               
## 15 com.xiaomi.mishop                 bundleID               
## 16 com.alipay.iphoneclient           bundleID               
## 17 com.wdk.hmxs                      bundleID               
## 18 com.xiaomi.miwatch.pro            bundleID               
## 19 com.heweather.weatherapp          bundleID               
## 20 tv.danmaku.bilianime              bundleID               
## 21 com.atebits.Tweetie2              bundleID               
## 22 com.dianping.dpscope              bundleID               
## 23 com.johnil.vvebo                  bundleID               
## 24 com.tmri.12123                    bundleID               
## 25 cn.mucang.ios.jiakaobaodianhuoche bundleID               
## 26 com.readdle.Scanner               bundleID               
## 27 <NA>                              <NA>

数据整形与预览

还是存在 NA,后面发现除通过 accessor.identifier 标记的是除网络访问之外的记录,bundleID 列是记录的 App 的网络链接请求,那就对数据帧变形。

library(tidyr)
app_privacy_report_meld_tb <-
  app_privacy_report_tb %>% 
  pivot_longer(cols = c(accessor.identifier, bundleID),
               names_to = "id_type", 
               values_to = "app_id"
               ) %>% 
  filter(!is.na(app_id)) %>%
  select(-id_type) %>% 
  select(app_id, everything())

meld_tb <-
app_privacy_report_meld_tb %>% 
  select(app_id, category, kind, type, timeStamp) %>% 
  mutate(
    app_id = as.factor(app_id),
    category = as.factor(category),
    kind = as.factor(kind),
    type = as.factor(type),
    timeStamp = ymd_hms(timeStamp)
  )

head(meld_tb)
## # A tibble: 6 × 5
##   app_id            category kind          type   timeStamp          
##   <fct>             <fct>    <fct>         <fct>  <dttm>             
## 1 com.xiaomi.mihome location intervalBegin access 2021-10-08 05:30:43
## 2 com.xiaomi.mihome location intervalEnd   access 2021-10-08 05:34:21
## 3 com.xiaomi.mihome location intervalBegin access 2021-10-08 05:34:24
## 4 com.xiaomi.mihome location intervalEnd   access 2021-10-08 05:34:53
## 5 com.xiaomi.mihome location intervalBegin access 2021-10-08 05:34:58
## 6 com.xiaomi.mihome location intervalEnd   access 2021-10-08 05:35:08
skimr::skim(meld_tb)
(#tab:accesor_n_bundle)Data summary
Name meld_tb
Number of rows 19687
Number of columns 5
_______________________
Column type frequency:
factor 4
POSIXct 1
________________________
Group variables None

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
app_id 0 1.00 FALSE 85 com: 9159, com: 4140, com: 2518, com: 429
category 9121 0.54 FALSE 4 loc: 9940, con: 290, pho: 288, cam: 48
kind 9121 0.54 FALSE 2 int: 5283, int: 5283
type 0 1.00 FALSE 2 acc: 10566, net: 9121

Variable type: POSIXct

skim_variable n_missing complete_rate min max median n_unique
timeStamp 0 1 2021-10-08 05:30:41 2021-10-11 14:36:29 2021-10-09 02:33:52 19671

经过整形的数据再看,发现总共有 85 款 App 请求了隐私数据,那么平均下来一款 App 就请求了 115.8058824 次。

第二次数据可视化

在 App Privacy Report 中,type 大类分为了两种 access, networkActivity, 对于 access 分类,在 category 中又有子分类 location, photos, contacts, camera。为了方便可视化,我们首先对这个部分进行更进一步整形,将网络请求补充到 category 中,然后进行可视化。

cat_to_type_tb <-
  meld_tb %>%
  mutate(
    # unfactor columns to avoid level missing
    category = as.character(category),
    type = as.character(type)) %>%
  mutate(
    # Do not use `ifelse`,
    # it does not support vectorization operation
    category = if_else(is.na(category),
                       type,
                       category)) %>%
  mutate(
    category = as.factor(category),
    type = as.factor(type)
  )

app_n_count_tb <-  
cat_to_type_tb %>% 
  group_by(app_id) %>%
  mutate(count = n()) %>%
  ungroup() %>%
  mutate(
    app_id = fct_reorder(app_id, count)
  )

app_n_count_tb %>%
  ggplot(aes(
    x = app_id,
    y = count
  )) +
  geom_col(aes(fill = type)) +
  coord_flip()

app_n_count_tb %>%
  ggplot(aes(
    x = app_id,
    y = count
  )) +
  geom_col(aes(fill = category)) +
  coord_flip()

由于 App 数量太多,而且 Bundle ID 还存在 com.apple.corelocation.CoreLocationVanilaWhenInUseAuthPromptPlugin 这种龙傲天般的命名,上面两张图的视觉效果还有很大优化空间,然而这已经足够确定一个问题了:米家 App 靠实力诠释了一骑绝尘是什么。且根据第二站图,可以发现米家似乎绝大多数请求都用在 location 定位上。

library(showtext)
## Loading required package: sysfonts
## Loading required package: showtextdb
showtext::showtext_auto()
cat_to_type_tb %>% 
  filter(app_id == "com.xiaomi.mihome") %>% 
  group_by(category) %>% 
  count() %>% 
  ggplot(aes(x = "", y = n, fill = category)) +
  geom_bar(stat = "identity", width = 1, color="white") +
  coord_polar("y", start = 0) +
  theme_void() +
  labs(title = "米家权限请求分布")

mj_time_begin_end_tb <-
cat_to_type_tb %>% 
  filter(app_id == "com.xiaomi.mihome") %>% 
  filter(category == "location") %>% 
  select(kind, timeStamp, category) %>% 
  # 此处实现很奇怪,应该可以用 pivot_wider 一步到位的
  pivot_wider(
    names_from = kind,
    values_from = timeStamp
    )
## Warning: Values are not uniquely identified; output will contain list-cols.
## * Use `values_fn = list` to suppress this warning.
## * Use `values_fn = length` to identify where the duplicates arise
## * Use `values_fn = {summary_fun}` to summarise duplicates
mj_time_tb <-
  tibble(
    begin = mj_time_begin_end_tb$intervalEnd[[1]],
    duration = mj_time_begin_end_tb$intervalEnd[[1]] - mj_time_begin_end_tb$intervalBegin[[1]]
  )

mj_time_tb %>% 
  mutate(
    hour_of_day =
      hour(begin)
  ) %>% 
  group_by(hour_of_day) %>% 
  count() %>% 
  ggplot(
    aes(x = hour_of_day,
        y = n)
  ) +
  geom_line() +
  scale_x_continuous(name = "时间", limits = c(0,24), expand = c(0,0)) +
  scale_y_continuous(name = "请求次数") +
  theme_linedraw()

总的来说,米家主要请求的因素数据就是定位,并且是夜以继日的工作,可以说是007了。只有在凌晨才舍得勉强克制一点。再结合 iOS 电池选项厘米啊米家出色的耗电量,应该没有冤枉。

关键是笔者米家中需要用到定位的自动化并没有开启(因为从来就没有按照预期正常工作过),所以勤劳的定位请求真的让人头大。

目前笔者的解决办法是关闭 App 的始终定位功能,只能从根源上解决问题,关键是对于使用影响非常有限(依赖地理围栏功能的小伙伴慎重考虑)

总结

总的来说,根据使用 R 分析 App_Privacy_Report 报告,笔者并未发现美团与微信的频繁访问,不过这并不能说明它们没有问题,因为我已经关掉了此二者的后台刷新以及始终定位功能,使得他两个没有办法实现频繁唤醒;不过让人意外的是,无意中发现了潜在的耗电大户,还是希望能克制一点。

声明:本文采用 R 语言是为境外团队 R-Core Team 开发软件;ndjson 包是境外冰岛开发者开发;tidyverse bundle 是新西兰开发者牵头开发;下列读取 iOS 15 系统日志的操作并未进行安全性和保密性测试,建议大家谨慎操作,或者拔掉网线并开启电磁屏蔽操作;不过写作的人是境内人员(战术后仰

米家版本为v6.11.201-build6.11.201.2


欢迎通过邮箱微博, Twitter以及知乎与我联系。也欢迎关注我的博客。如果能对我的 Github 感兴趣,就再欢迎不过啦!