[Android][Google Play]Support 64-bit architectures采坑(一)

项目jni代码比较旧,好几年没维护了……
1、项目采用ndk-build编译方式
2、项目使用Android.mk、Application.mk
3、修改Application.mk中的APP_ABI

APP_ABI := armeabi-v7a arm64-v8a

4、ndk-build NDK_DEBUG=1

error: invalid argument '-std=gnu99' not allowed with 'C++'

5、修改Android.mk,移除’-std=gnu99’部分,继续编译,卡在了libyuv
6、关于libyuv与libjpeg的问题。。。这是一个超级大坑,因为最新的GitHub源代码采用了cmake的方式,于是单独编译了对应版本的so动态库,可是libyuv死活找不到部分jpeg的函数符号

zeonadmindeMac-mini-2:build zeonadmin$ make
[ 49%] Built target yuv
[ 51%] Built target yuvconvert
[ 52%] Linking CXX shared library libyuv.so
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:78: error: undefined reference to 'jpeg_std_error'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:89: error: undefined reference to 'jpeg_CreateDecompress'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:0: error: undefined reference to 'jpeg_resync_to_restart'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:96: error: undefined reference to 'jpeg_destroy_decompress'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:121: error: undefined reference to 'jpeg_read_header'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:241: error: undefined reference to 'jpeg_abort_decompress'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:516: error: undefined reference to 'jpeg_start_decompress'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:542: error: undefined reference to 'jpeg_read_raw_data'
/Users/zeonadmin/GitHub/libyuv/source/mjpeg_decoder.cc:526: error: undefined reference to 'jpeg_abort_decompress'
clang++: error: linker command failed with exit code 1 (use -v to see invocation)
make[2]: *** [libyuv.so] Error 1
make[1]: *** [CMakeFiles/yuv_shared.dir/all] Error 2
make: *** [all] Error 2

用ndk的nm查看符号,全部都在,卧槽不懂了。。。
只好单独将libyuv用单个android studio project的jni来build,结果就成功了

[armeabi-v7a] Gdbserver      : [arm-linux-androideabi] libs/armeabi-v7a/gdbserver
[armeabi-v7a] Gdbsetup       : libs/armeabi-v7a/gdb.setup
[armeabi-v7a] Install        : libjpeg.so => libs/armeabi-v7a/libjpeg.so
[armeabi-v7a] Install        : libyuv.so => libs/armeabi-v7a/libyuv.so

思考了一下原因,用的ndk都是来源AndroidStudio的ndk,TOOLCHAIN应该都是clang…
使用make VERBOSE=1 查看具体的链接库信息。。。
无解。。。
****2019年6月10日 周一****
休了一周的年假回来,整个人都通透了,终于发现了原因在build.sh上:

JPEG_SHARED_LIB=${JPEG_LIB_PATH}/libjpeg.so
...
 
cmake -G"Unix Makefiles" \
    -DANDROID_ABI=armeabi-v7a \
    -DANDROID_PLATFORM=android-${ANDROID_VERSION} \
    -DANDROID_TOOLCHAIN=${TOOLCHAIN} \
    -DANDROID_ARM_MODE=arm \
    -DCMAKE_ASM_FLAGS="--target=arm-linux-androideabi${ANDROID_VERSION}" \
    -DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
    -DCMAKE_C_FLAGS="${CFLAGS} -Wall -Werror -Wno-unused-parameter -fexceptions" \
    -DJPEG_INCLUDE_DIR=${JPEG_INCLUDE_DIR} \
    -DJPEG_LIBRARY=${JPEG_SHARED_LIB} \
    -DJPEG_SHARED_LIB=${JPEG_SHARED_LIB} \
    ../

注意上面的JPEG_SHARED_LIB使用的是通配符变量,这样的写法cmake链接库的时候找不到,必须给动态库写绝对路径…
7、鉴于Android的样例推荐都采用了cmake编译,于是根据原来的Android.mk,重新翻译成CMakeLists.txt,还是很方便的,就是新东西里面的变量、函数不熟,导致试错成本太高,建议从AndroidStudio新建cmake编译的工程样例开始。

[Android][Google Play]Support 64-bit architectures

来源:https://developer.android.com/distribute/best-practices/develop/64-bit
从 2019 年 8 月 1 日开始,您在 Google Play 上发布的应用必须支持 64 位架构。64 位 CPU 能够为您的用户提供更快、更丰富的体验。添加 64 位的应用版本不仅可以提升性能、为未来创新创造条件,还能针对仅支持 64 位架构的设备做好准备。

您的应用是否包含 64 位库?
要确定应用是否包含 64 位库,最简单的方法就是检查 APK 文件的结构。在编译时,APK 会与应用所需的所有原生库打包在一起。原生库会根据 ABI 而存储在不同的文件夹中。您的应用无需支持所有 64 位架构,但对于您支持的每种原生 32 位架构,则应用都必须包含相应的 64 位架构。
对于 ARM 架构,32 位库位于 armeabi-v7a 中。对应的 64 位库位于 arm64-v8a 中。
对于 x86 架构,请查找 x86(32 位)和 x86_64(64 位)。

=======================================================
对于Google的良(流)苦(氓)用(行)心(为),不得不对代码中的jni部分重新梳理,接下来会分享踩过的坑……

[Android][targetSdkVersion][API26]关于GooglePlay要求升级目标版本到26的神坑

事实证明Google的陷阱很深,俺被它简单的sdk变更文档给坑了……
链接:https://developer.android.com/distribute/best-practices/develop/target-sdk#prenougat

第一点:后台执行限制
第二点:通知渠道

所有切到后台的程序,google的Nexus上都会转入background标识,这时收到Push消息唤醒GCM来启动app……Duang,华丽的Exception让我欲生欲死:

    static void runIntentInService(Context context, Intent intent, String className) {
        Object var3 = LOCK;
        synchronized(LOCK) {
            if(sWakeLock == null) {
                PowerManager pm = (PowerManager)context.getSystemService("power");
                sWakeLock = pm.newWakeLock(1, "GCM_LIB");
            }
        }
 
        Log.v("GCMBaseIntentService", "Acquiring wakelock");
        sWakeLock.acquire();
        intent.setClassName(context, className);
        context.startService(intent);
    }

从上面GCM的代码context.startService,“当应用试图调用 startService() 而 startService 又被禁止时,startService() 会抛出异常。”

那么只能从两个方面解决了
1、程序持有前台通知
2、Android8.0不能使用GCM Push通知了

问题2替换push来源……你逗我玩么……
继续问题1,Android8.0前台通知,就是上面第二点,通知渠道的变化,拥有后台Service的app,通知栏位有消除不掉的提示,你的app正在运行中……

尼玛通知栏位的问题更严重,用户投诉差评,麻蛋这是要玩死我,只好将targetSdkVersion降级到24,移除代码中关于8.0的新特性部分代码,将问题搁置。。。

预知后事如何,请听下回分解

[Android][targetSdkVersion]应用程序更新的指定目标必须是Android8.0(API等级26)


最近一次更新APP时,Google Play提示了这个信息。。。
历史的欠账总归要补上,貌似有点麻烦。。。
一步一个脚印,记录targetSdkVersion从21升级到26遇到的问题吧。。。
官方SDK版本更新总览:https://developer.android.com/about/versions/marshmallow/android-6.0-changes#behavior-runtime-permissions
官方TargetSDK说明:https://developer.android.com/distribute/best-practices/develop/target-sdk
中文PDF版:Google Play 目标 API 等级(targetSdkVersion)重要变更要求  _  Android Developers

1、Runtime Permission(在运行时请求权限
这是 Android 6.0(API 级别 23)引进的,运行时态才去检查权限,而不是安装的时候。权限划分为多个保护级别,第三方应用受到下面三个级别的影响:normal, signature, dangerous.

Normal permissions

权限被声明为Normal级别,任何应用都可以申请,在安装应用时,不会直接提示给用户,点击全部才会展示。
截至Android8.1,下面的权限都是正常许可:

ACCESS_LOCATION_EXTRA_COMMANDS
ACCESS_NETWORK_STATE
ACCESS_NOTIFICATION_POLICY
ACCESS_WIFI_STATE
BLUETOOTH
BLUETOOTH_ADMIN
BROADCAST_STICKY
CHANGE_NETWORK_STATE
CHANGE_WIFI_MULTICAST_STATE
CHANGE_WIFI_STATE
DISABLE_KEYGUARD
EXPAND_STATUS_BAR
GET_PACKAGE_SIZE
INSTALL_SHORTCUT
INTERNET
KILL_BACKGROUND_PROCESSES
MANAGE_OWN_CALLS
MODIFY_AUDIO_SETTINGS
NFC
READ_SYNC_SETTINGS
READ_SYNC_STATS
RECEIVE_BOOT_COMPLETED
REORDER_TASKS
REQUEST_COMPANION_RUN_IN_BACKGROUND
REQUEST_COMPANION_USE_DATA_IN_BACKGROUND
REQUEST_DELETE_PACKAGES
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
SET_ALARM
SET_WALLPAPER
SET_WALLPAPER_HINTS
TRANSMIT_IR
USE_FINGERPRINT
VIBRATE
WAKE_LOCK
WRITE_SYNC_SETTINGS

Signature permissions

权限被声明为Signature级别,只有和该apk(定义了这个权限的apk)用相同的私钥签名的应用才可以申请该权限。

Dangerous permissions

权限被声明为Dangerous级别,任何应用都可以申请,在安装应用时,会直接提示给用户。

android.permission.READ_CALENDAR
android.permission.WRITE_CALENDAR
android.permission.CAMERA
android.permission.READ_CONTACTS
android.permission.WRITE_CONTACTS
android.permission.GET_ACCOUNTS
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_COARSE_LOCATION
android.permission.RECORD_AUDIO
android.permission.READ_PHONE_STATE
android.permission.READ_PHONE_NUMBERS
android.permission.CALL_PHONE
android.permission.ANSWER_PHONE_CALLS
android.permission.READ_CALL_LOG
android.permission.WRITE_CALL_LOG
android.permission.ADD_VOICEMAIL
android.permission.USE_SIP
android.permission.PROCESS_OUTGOING_CALLS
android.permission.BODY_SENSORS
android.permission.SEND_SMS
android.permission.RECEIVE_SMS
android.permission.READ_SMS
android.permission.RECEIVE_WAP_PUSH
android.permission.RECEIVE_MMS
android.permission.READ_EXTERNAL_STORAGE
android.permission.WRITE_EXTERNAL_STORAGE
android.permission.SYSTEM_ALERT_WINDOW

在manifest文件中查找需要添加权限获取的类型,目前项目中使用了下面几个权限分组:Camera、Storage、Location、Phone。
Camera权限分组:比较简单,在扫描二维码的activity启动之前验证权限即可,代码异步回调调用。
Storage权限分组:需要根据调用的文件路径来区分,如下图所示,外部存储和内部存储的区别

也就是说访问 Environment.getExternalStorageDirectory类型的,都需要加上权限判断(还包括getExternalStoragePublicDirectory函数指定的参数、各种公共目录如DCIM、Music、Pictures、Movies、Downloads、Documents等等)。

2、FileUriExposedException异常
发现以前代码有些用法不对,如果是为了保存文件,那就需要提示要求权限;如果是为了分享文件,则需要改用Android7.0提出的FileProvider,同时在intent中设置Uri的临时权限 FLAG_GRANT_READ_URI_PERMISSION ,并且该权限在对方app退出后自动失效(intent的stack内有效)。
另外选拍照图片是一个特例,原先传入的是DCIM下的文件路径,现在要改成用FileProvider转换成Uri,否则相机会抛出异常:

android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/Camera/IMG_20180713_171929.jpg exposed beyond app through ClipData.Item.getUri()
    at android.os.StrictMode.onFileUriExposed(StrictMode.java:1972)
    at android.net.Uri.checkFileUriExposed(Uri.java:2371)
    at android.content.ClipData.prepareToLeaveProcess(ClipData.java:963)
    at android.content.Intent.prepareToLeaveProcess(Intent.java:10231)
    at android.content.Intent.prepareToLeaveProcess(Intent.java:10216)
    at android.app.Instrumentation.execStartActivity(Instrumentation.java:1661)
    at android.app.Activity.startActivityForResult(Activity.java:4617)
    at android.support.v4.app.BaseFragmentActivityJB.startActivityForResult(BaseFragmentActivityJB.java:48)
    at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:77)
    at android.support.v4.app.ActivityCompatJB.startActivityForResult(ActivityCompatJB.java:26)
    at android.support.v4.app.ActivityCompat.startActivityForResult(ActivityCompat.java:146)
    at android.support.v4.app.FragmentActivity.startActivityFromFragment(FragmentActivity.java:937)
    at android.support.v4.app.FragmentActivity$HostCallbacks.onStartActivityFromFragment(FragmentActivity.java:1046)
    at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:956)
    at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:945)

我们来看看FileUriExposedException是个什么鬼。。。

The exception that is thrown when an application exposes a file:// Uri to another app.

This exposure is discouraged since the receiving app may not have access to the shared path. For example, the receiving app may not have requested the Manifest.permission.READ_EXTERNAL_STORAGE runtime permission, or the platform may be sharing the Uri across user profile boundaries.

Instead, apps should use content:// Uris so the platform can extend temporary permission for the receiving app to access the resource.

This is only thrown for applications targeting Build.VERSION_CODES.N or higher. Applications targeting earlier SDK versions are allowed to share file:// Uri, but it’s strongly discouraged.

总而言之,就是Android不再允许在app中把file://Uri暴露给其他app,包括但不局限于通过Intent或ClipData 等方法。

public static Uri fixFileProviderUri(Context context, File file, Intent action) {
    // FileProvider修正文件uri
    Uri uri = FileProvider.getUriForFile(context,
           context.getString(R.string.file_provider_auth), file);
    action.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    return uri;
}

通过FileProvider提供的getUriForFile,将file://路径转成content://,注意!这里需要加上FLAG_GRANT_WRITE_URI_PERMISSION,同时APP应该请求Camera和Storage权限。。。。。。
拍照和选图片搞定了,结果裁剪图片又出了问题,原来APP提供出去的路径,需要给裁剪APP赋值可写的权限:

for (ResolveInfo res : list) {
    // API 24 需要赋予每一个裁剪APP访问文件的读写权限
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        // 图片文件只读
        context.getContext().grantUriPermission(res.activityInfo.packageName, imageFile,
            Intent.FLAG_GRANT_READ_URI_PERMISSION);
        // 裁剪输出文件需要可写
        context.getContext().grantUriPermission(res.activityInfo.packageName, uri,
            Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    }
}

这里grantUriPermission如果传入的包名是自身app,等同于上面的addFlags添加的Intent.FLAG_GRANT_READ_URI_PERMISSION。
我滴妈呀,FileProvider的坑真多,Intent分享出去的Uri即要自己可读,又要给别的APP赋予可写的权限。。。。。。
参考文档:http://gelitenight.github.io/android/2017/01/29/solve-FileUriExposedException-caused-by-file-uri-with-FileProvider.html
链接打不开可以点PDF文档快照:使用FileProvider解决file___ URI引起的FileUriExposedException

如果FileProvider的坑到这里就结束了,我会灰常开森。。。
下面介绍由content://引申的又一个坑,由于Intent之间传递禁止了file://头,所以APP内部传递文件句柄,也必须经FileProvider的手,那么在onActivityResult得到的Uri也是content://,大家可能会想,以前拿到这种类型已经有开源的方法嘛,通过context.getContentResolver().query查询column = “_data”得到真实的文件路径。。。可惜抛出了异常:

    Caused by: java.lang.IllegalArgumentException: column '_data' does not exist

查阅官方对FileProvider的详细解释,它给出了标准的流程如下(Access the requested file):

    /*
     * When the Activity of the app that hosts files sets a result and calls
     * finish(), this method is invoked. The returned Intent contains the
     * content URI of a selected file. The result code indicates if the
     * selection worked or not.
     */
    @Override
    public void onActivityResult(int requestCode, int resultCode,
            Intent returnIntent) {
        // If the selection didn't work
        if (resultCode != RESULT_OK) {
            // Exit without doing anything else
            return;
        } else {
            // Get the file's content URI from the incoming Intent
            Uri returnUri = returnIntent.getData();
            /*
             * Try to open the file for "read" access using the
             * returned URI. If the file isn't found, write to the
             * error log and return.
             */
            try {
                /*
                 * Get the content resolver instance for this context, and use it
                 * to get a ParcelFileDescriptor for the file.
                 */
                mInputPFD = getContentResolver().openFileDescriptor(returnUri, "r");
            } catch (FileNotFoundException e) {
                e.printStackTrace();
                Log.e("MainActivity", "File not found.");
                return;
            }
            // Get a regular file descriptor for the file
            FileDescriptor fd = mInputPFD.getFileDescriptor();
            ...
        }
    }

上面这段话的核心意思,就是通过getContentResolver().openFileDescriptor持有Intent传递过来的文件句柄,已经赋予了相应的读写权限。
So,到底怎么搞好呀?如果只是简单的拿文件,直接读数据倒简单;不过大部分代码应该是存储了文件路径,并且在APP关闭或者手机重启之后继续访问;
这个问题有点棘手,尽量在onActivityResult之后立即处理数据,否则拷贝文件带来性能开销、管理文件也需要额外处理。
唔,步子迈的太大会扯着蛋。。。考虑到APP内部跳转Intent携带的Uri路径是以本包名格式固定的,特别处理一下算了。
特别的举例:EXTERNAL_DIR,一般图省事,在res/xml/filepaths.xml中会定义外部存储的根路径

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- root-path/files-path/cache-path/external-path -->
    <external-path path="/" name="EXTERNAL_DIR" />
</paths>

那么转换Uri得到的路径是这样子的:

    content://包名.fileprovider/EXTERNAL_DIR/xxxx

So,我们通过Environment.getExternalStorageDirectory得到根路径,替代content://包名.fileprovider/EXTERNAL_DIR部分,就拿到了真实的文件路径。
哦,顺嘴提一句,上面这个函数虽然要求Storage权限,但是实际上无权限时,也能得到路径,只是不能访问读写文件而已。
注意,这样的替换只能针对APP内部Intent跳转,并且传递文件路径是在External Storage下的!!!

// APP内部Intent如果采用的EXTERNAL_DIR路径,可以直接转译成file://
public static final String EXTERNAL_DIR = "EXTERNAL_DIR";
@TargetApi(Build.VERSION_CODES.N)
public static Uri parseSelfUri(Uri uri) {
    final String path = uri.getPath();
    final String prefix = "/" + EXTERNAL_DIR;
    if (path.startsWith(prefix)) {
       final File sdcard = Environment.getExternalStorageDirectory();
       return Uri.fromFile(new File(path.replace(prefix, sdcard.getPath())));
    }
    return uri;
}
@TargetApi(Build.VERSION_CODES.N)
public static boolean isSelfPackageUri(Context context, Uri uri) {
    final String auth = uri.getAuthority();
    final String selfAuth = context.getString(R.string.file_provider_auth);
    if (auth != null && auth.equalsIgnoreCase(selfAuth)) {
        return true;
    }
    return false;
}

自测了一下APP内部传递Uri.fromFile(File)数据的情况,只有Intent#setData(Uri)与Intent#setClipData(ClipData)这两种传递Uri类型的方式,才需要限定采用content//auth.fileprovider/ 这种格式。
注意我用的是“两种传递Uri类型”,查看Intent的method,setData类型包含:setData、setDataAndNormalize、setDataAndType、setDataAndTypeAndNormalize。
God bless me!
审视代码中用到的Uri.fromFile以及Intent#setData、Intent#setClipData,只有选图片文件、裁剪图片才采用了上述类型。于是修改Intent返回的Uri用FileProvider修复,在应答里采用上面的parseSelfUri还原。
至此,FileProvider应该算是全部搞定,下面继续爬坑。(总结一下,FileProvider强制将APP内文件变成沙盒,对外不可见;为了分享对外可见,又额外的增加了Intent flag权限;基本上所有的核心问题都围绕着这个,蛋疼的还要结合Storage Permissions)

3、后台执行限制
包括后台Service、后台广播、后台位置限制三部分
① StartService
该方法只能是应用在前台时调用,否则抛出ANR异常。对此,提供了新的startForgroundService,启动前台服务。在Service启动后,需要调用startForground,提供一个前台Notification说明。这部分将在下边与通知的变更一块说明。
② 隐式广播
③ 后台位置限制

4、通知渠道
android.app.Notification.Builder增加了一个String channelId的构造参数,它是由NotificationManager#createNotificationChannel创建;
注意Notification.Builder去掉了setDefaults(Notification.DEFAULT_SOUND),由NotificationChannel的importance属性决定;同时增加了enableVibration、enableLights,实现震动和闪烁的提醒。
另外,上文startForground的第一个参数id,也是Notification的通知id,一般是默认自增字段,需要大于0;第二个参数是Notification对象,使用默认的channelId创建即可。

关于js创建sign的进一步思路

关于js创建sign的进一步思路
对于代理使用js创建sign,大家已经熟悉了,现在我们能不能把这种js内部函数调用,变成外部可以访问的http请求呢?抱着这个目的,我开始了对js中webscoekt的使用研究——因为websocket是允许在html中跨域访问的。
1、js请求websocket
ws的请求地址是这样的:
ws://echo.websocket.org/
它的回调event是这样的:

        websocket = new WebSocket(wsUri);
        websocket.onopen = function(evt) {
            onOpen(evt)
        };
        websocket.onclose = function(evt) {
            onClose(evt)
        };
        websocket.onmessage = function(evt) {
            onMessage(evt)
        };
        websocket.onerror = function(evt) {
            onError(evt)
        };

websocket使用的是TCP长链接,即能收message,也能发送message,这样就只需要起一个websocket的server,让server给js发送请求sign的指令即可,应答由server异步接收。
2、WebSocket Server
我打算用另一个websocket client去链接server,与js端的websocket client通讯,server把命令指令进行中转即可;也可以让server兼做http server,省略websocket server转发client请求。
请求过程如下:
websocket client ==>> websocket server ==>> websocket – js client
数据返回是倒着来:
websocket client <<== websocket server <<== websocket – js client
于是在client中,提交sign请求给server,server转发请求给js client;
js client计算sign,异步得到结果后,传回给server;
server再将js client返还的数据包,转发给client,完成一次Request&Response
3、Http Server
虽然websocket client已经够用了,但是需要使用websocket对于html页面也是一种负担和技术门槛,而且数据协议需要定义好,才能在websocket server中转发请求与应答。加上一层http server是为了简化html使用,还能形成API,加一层Ngnix后,可以扩大并发,瓶颈目前在js client,毕竟手机或者模拟器跑app上的js有点慢。融合http server到websocket client中即可,这个就不多讲了,目前还在研究中。

附录:
1、websocket server使用autobahn
2、http server使用aiohttp

Android[com.android.camera.action.CROP]剪裁图片详解

Android系统默认提供了action=com.android.camera.action.CROP来调用裁剪图片,具体参数列表如下:

这里展示一下裁剪已有图片文件的函数:

public static Intent buildCropIntent(String action, CropParams params, Uri imageFile) {
    Intent intent = new Intent(action)
            .setDataAndType(imageFile, "image/*")
            .putExtra("aspectX", params.aspectX)
            .putExtra("aspectY", params.aspectY)
            .putExtra("outputX", params.outputX)
            .putExtra("outputY", params.outputY)
            .putExtra("return-data", params.returnData)
            .putExtra("outputFormat", params.outputFormat)
            .putExtra("noFaceDetection", params.noFaceDetection)
            .putExtra("scale", params.scale)
            .putExtra("scaleUpIfNeeded", params.scaleUpIfNeeded)
            .putExtra(MediaStore.EXTRA_OUTPUT, params.uri);
 
    if (params.crop) {
        intent.putExtra("crop", "true");
    }
    return intent;
}

注意imageFile是输入的文件路径,EXTRA_OUTPUT参数对应的,是我们指定的输出文件路径。

在onActivityResult中处理结果:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode == Activity.RESULT_OK) {
        // 在return-data=true时,data中会有extra;否则输出到文件
    }
}

很意外的发现,有些情况下params.uri对应的文件长度为0,实测后发现,不同的crop app处理流程不一样,例如Android6.0模拟器自带相册在保存裁剪后图片时,写入到文件是异步的,也就意味着在onActivityResult返回时,裁剪后的image文件还未保存完整。。。然而使用小米手机测试,无论如何它都在裁剪界面转圈保存成功后,才有onActivityResult返回。。。

于是这样带来一个棘手的问题,对这个裁剪后的图片Uri如何处理?不过,我发现大家好像都使用了另一段代码来工作正常:

private Bitmap decodeUriAsBitmap(Uri uri){
    Bitmap bitmap = null;
    try {
        bitmap = BitmapFactory.decodeStream(getContentResolver().openInputStream(uri));
    } catch (FileNotFoundException e) {
        e.printStackTrace();
        return null;
    }
    return bitmap;
}

真的不可思议,改成这样的方式显示图片,居然工作正常,于是加上日志再看看:

public static Bitmap decodeUriAsBitmap(Context context, Uri uri) {
    if (context == null || uri == null) return null;
 
    Bitmap bitmap;
    try {
        InputStream is = context.getContentResolver().openInputStream(uri);
        try {
            Log.w(TAG, "decodeUriAsBitmap Before decode is.length = " + is.available());
        } catch (IOException e) {
            e.printStackTrace();
        }
        bitmap = BitmapFactory.decodeStream(is);
        try {
            Log.w(TAG, "decodeUriAsBitmap After decode is.length = " + is.available());
        } catch (IOException e) {
           e.printStackTrace();
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
        return null;
    }
    return bitmap;
}

观察日志输出:
decodeUriAsBitmap Before decode is.length = 12165
decodeUriAsBitmap After decode is.length = 0
但也存在少数Before情况=0的时候,看来通过context.getContentResolver().openInputStream(uri)得到的InputStream的确好用。

为了保险起见,在使用裁剪得到的Uri时,需要线程异步等待io完成后,再使用该文件;
这里采用判断文件长度的方式来决定文件是否可用:

public static boolean isPhotoReallyCropped(Uri uri) {
    File file = new File(uri.getPath());
    long length = file.length();
    Log.w(TAG, "isPhotoReallyCropped uri = " + uri.getPath() + ", length = " + length);
    return length > 0;
}

线程异步判断:

private void checkInputFileLength() throws IOException {
    // 检查输入文件是否存在,如果不存在,是否等待超时
    if (millionSecondTimeOut > 0) {
        File f = new File(getPath());
        if (!f.exists()) throw new FileNotFoundException();
        if (f.length() == 0) {
            long time = System.currentTimeMillis();
            long time2 = time;
            while (f.length() == 0) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    return;
                }
                time2 = System.currentTimeMillis();
                if (time2 - time > millionSecondTimeOut)
                    break;
            }
            time2 = System.currentTimeMillis();
            Log.w("Network", "checkInputFileLength cost: " + (time2-time));
            if (f.length() == 0) throw new FileNotFoundException();
        }
    }
}

参考文章:
http://www.linuxidc.com/Linux/2012-11/73939.htm
http://www.linuxidc.com/Linux/2012-11/73940.htm

扩展阅读,自定义实现裁剪界面:
https://github.com/edmodo/cropper

Android[Camera][PackageManager.FEATURE_CAMERA_ANY]

在判断手机是否存在摄像头的时候,简单的使用了如下函数:

    public static boolean checkIfExistCameraHardware(Context context) {
        if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
            return true;
        return false;
    }

后来发现了在小米手机Android4.1.1系统上返回值不对,查了一下文档,找到了问题所在。

“FEATURE_CAMERA_ANY Added in API level 17”
在Android API 17才引入使用(Android4.2),所以这么写是不对的,至少声明

@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)

不过某些机型即使在Android4.2以上,也会出bug,只能换一种方式实现:

    public static boolean hasCamera() {
        return android.hardware.Camera.getNumberOfCameras() > 0;
    }

记得在Manifest中声明Camera权限:

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

参考文章:https://stackoverflow.com/questions/19458342/pm-hassystemfeaturepackagemanager-feature-camera-any-not-giving-corerct-answer

Android[HttpURLConnection]GET得到的数据中文是乱码

最近打算写一个小说阅读App,在使用HttpURLConnection GET请求中文网页,返回的结果中,某些中文字符显示成了乱码:???,在查看了请求的头,以及返回的header关于字符串编码,确认是UTF-8编码后,最终确认问题出在了读取数据流上。

    public String readAllInputStream(InputStream in) {
        if (null == in) return null;
 
        StringBuffer result = new StringBuffer();
        final byte[] buffer = new byte[16 * 1024];
        try {
            while (true)
            {
                int n = in.read(buffer);
                if (n == -1)
                    break;
 
                if (n > 0)
                {
                    result.append(new String(buffer, 0, n));
                }
            }
            return result.toString();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

每次读取16KB数据后,将其转换为了String,这里正好可能卡在了汉字编码的中间,将完整的UTF-8编码的4个汉字字符分割开,修改为InputStreamReader带charset读取后,问题修复了。

    public static String readAllInputStream(InputStream in) {
        if (null == in) return null;
 
        StringBuffer result = new StringBuffer();
        final char[] buffer = new char[16 * 1024];
        try {
            InputStreamReader isr = new InputStreamReader(in, "UTF-8");
            while (true)
            {
                int n = isr.read(buffer);
                if (n == -1)
                    break;
 
                if (n > 0)
                {
                    result.append(new String(buffer, 0, n));
                }
            }
            return result.toString();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

参考阅读:http://blog.csdn.net/lmj623565791/article/details/23562939

Android[Exception][java.lang.UnsatisfiedLinkError]

上篇二维码的应用使用了jni代码,在Nexus5X(Android7.1.1)上会crash,抛出了如下异常:

AndroidRuntime: java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[zip file “/data/app/…/base.apk”],nativeLibraryDirectories=[/data/app/…/lib/arm64, /system/fake-libs64, /data/app/…/base.apk!lib/arm64-v8a, /system/lib64, /system/lib64, /vendor/lib64]]] couldn’t find “libxxx.so”
AndroidRuntime: at java.lang.Runtime.loadLibrary…

在apk包里找不到对应的so文件,在指定的目录下也找不到,于是抛出异常;可以看到lib加载的类型是arm64-v8a,的确是没有,我们来看看别人是怎么说的。
http://blog.csdn.net/qiuchangyong/article/details/50040579

11.APP_ABI目前能取得值包括:(1)、32位:armeabi、armeabi-v7a、x86、mips;(2)、64位:arm64-v8a,x86_64, mips64;

12.注意事项:(1)、目前模拟器只有x86_64的没有arm64-v8a的;(2)、在用真机测试armv8-a时,最好先通过adb shell, cat  /proc/cpuinfo ,来查看下真机是否是支持armv8-a;(3)、arm32和arm64有些配置参数不能共存,如-msoft-float仅在arm32位下支持,在arm64位下是不支持的.

13.使用这个命令可以获得本机的arch:adb shell getprop ro.product.cpu.abi

在本机模拟器上运行时,发现也抛出了这个异常,缺少x86_64导致的,顺利解决。

Android[二维码]ZXing的应用(二)

本以为大功告成,可以歇息了,可是测试的一大波问题来了。。。
1、对准电脑屏幕上的二维码,反复对焦就是扫不出结果
2、扫码界面锁屏后,再打开,画面停止
3、Nexus 5X(Android7.1.1)扫描界面相机画面上下左右颠倒

先说说问题2,上真机调试,发现CaptureActivity中的hasSurface值在onPause为false,搜索该值的赋值,居然是抄错了地方。。。

   @Override
    public void surfaceCreated(SurfaceHolder holder) {
        if (holder == null) {
            Log.e(TAG, "*** WARNING *** surfaceCreated() gave us a null surface!");
        }
        if (!hasSurface) {
            hasSurface = true;
            initCamera(holder);
        }
    }
 
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
    }
 
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        hasSurface = false;
    }

应该是在surfaceDestroyed时重置,结果写在了surfaceChanged中,囧。。。

再看看问题1,这是个很有意思的问题,原因在前一篇文章中解码过程将横竖屏切换导致的,上代码:

    // 横屏换竖屏
    byte[] rotatedData = new byte[data.length];
    for (int y = 0; y < height; y++) {
      for (int x = 0; x < width; x++)
        rotatedData[x * height + height - y - 1] = data[x + y * width];
    }

实际中真机测试,这个横屏换竖屏的时间开销很大,大约在15000ms;我们再来看看自动对焦的问题:

   private static final long AUTO_FOCUS_INTERVAL_MS = 2000L;

自动对焦是在上一次对焦结束后,等待该时间后,再启动新一轮的对焦动作,结合上面的时间开销,大家不难想象到为什么出现反复对焦的问题,特别点,将这个自动对焦间隔改成1s甚至更短。

找到了问题,解决就很简单了,初步动作是采用jni代码替代上面java部分的横竖屏切换

   JNIEXPORT jbyteArray JNICALL Java_xxxxxx_rotateSource
  (JNIEnv *env, jclass clz, jbyteArray jdata, jint dataWidth, jint dataHeight)
{
    jbyteArray jbuf = env->NewByteArray(env->GetArrayLength(jdata));
    jboolean result = JNI_FALSE;
    jbyte* pRawData = env->GetByteArrayElements(jbuf, &result);
 
    char *buffer = (char *) env->GetByteArrayElements(jdata, JNI_FALSE);
    for (int y = 0; y < dataHeight; y++) {
        for (int x = 0; x < dataWidth; x++) {
            pRawData[x * dataHeight + dataHeight - y - 1] = buffer[x + y * dataWidth];
        }
    }
 
    env->ReleaseByteArrayElements(jdata, (jbyte *) buffer, 0);
    env->ReleaseByteArrayElements(jbuf, pRawData, JNI_ABORT);
    return jbuf;
}

采用native的rotateSource方法,真机实测2560*1440的相机平均只消耗了150ms不到,虽然jni上下文切换耗时了点,可这效率比java代码真心高了10倍。如果我们进一步优化,可以不用将横屏数据全部转换,只取扫描区域的像素就足够了,这样会更快;甚至更进一步,将相机数据直接扔给jni解码,这样会更快,给一个全jni解码的项目:https://github.com/heiBin/QrCodeScanner

另外,解码的时间开销也可以省一部分,如果你只解码二维码,可以只指定两种解码格式:

   // The prefs can't change while the thread is running, so pick them up once here.
    if (decodeFormats == null || decodeFormats.isEmpty()) {
      SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
      decodeFormats = EnumSet.noneOf(BarcodeFormat.class);
      if (prefs.getBoolean(PreferencesActivity.KEY_DECODE_QR, true)) {
        decodeFormats.addAll(DecodeFormatManager.QR_CODE_FORMATS);
      }
      if (prefs.getBoolean(PreferencesActivity.KEY_DECODE_DATA_MATRIX, true)) {
        decodeFormats.addAll(DecodeFormatManager.DATA_MATRIX_FORMATS);
      }
    }
    hints.put(DecodeHintType.POSSIBLE_FORMATS, decodeFormats);

关于问题3,咱们先看一篇关于相机orientation的文章,扫盲一下:

http://blog.csdn.net/wangbaochu/article/details/44345903

咱们对照着再看看代码:

CameraConfigurationManager:

   /**
   * Reads, one time, values from the camera that are needed by the app.
   */
  void initFromCameraParameters(OpenCamera camera) {
    Camera.Parameters parameters = camera.getCamera().getParameters();
    WindowManager manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    Display display = manager.getDefaultDisplay();
 
    int displayRotation = display.getRotation();
    int cwRotationFromNaturalToDisplay;
    switch (displayRotation) {
      case Surface.ROTATION_0:
        cwRotationFromNaturalToDisplay = 0;
        break;
      case Surface.ROTATION_90:
        cwRotationFromNaturalToDisplay = 90;
        break;
      case Surface.ROTATION_180:
        cwRotationFromNaturalToDisplay = 180;
        break;
      case Surface.ROTATION_270:
        cwRotationFromNaturalToDisplay = 270;
        break;
      default:
        // Have seen this return incorrect values like -90
        if (displayRotation % 90 == 0) {
          cwRotationFromNaturalToDisplay = (360 + displayRotation) % 360;
        } else {
          throw new IllegalArgumentException("Bad rotation: " + displayRotation);
        }
    }
    Log.i(TAG, "Display at: " + cwRotationFromNaturalToDisplay);
 
    int cwRotationFromNaturalToCamera = camera.getOrientation();
    Log.i(TAG, "Camera at: " + cwRotationFromNaturalToCamera);
 
    // Still not 100% sure about this. But acts like we need to flip this:
    if (camera.getFacing() == CameraFacing.FRONT) {
      cwRotationFromNaturalToCamera = (360 - cwRotationFromNaturalToCamera) % 360;
      Log.i(TAG, "Front camera overriden to: " + cwRotationFromNaturalToCamera);
    }
 
    /*
    SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
    String overrideRotationString;
    if (camera.getFacing() == CameraFacing.FRONT) {
      overrideRotationString = prefs.getString(PreferencesActivity.KEY_FORCE_CAMERA_ORIENTATION_FRONT, null);
    } else {
      overrideRotationString = prefs.getString(PreferencesActivity.KEY_FORCE_CAMERA_ORIENTATION, null);
    }
    if (overrideRotationString != null && !"-".equals(overrideRotationString)) {
      Log.i(TAG, "Overriding camera manually to " + overrideRotationString);
      cwRotationFromNaturalToCamera = Integer.parseInt(overrideRotationString);
    }
     */
 
    cwRotationFromDisplayToCamera =
        (360 + cwRotationFromNaturalToCamera - cwRotationFromNaturalToDisplay) % 360;
    Log.i(TAG, "Final display orientation: " + cwRotationFromDisplayToCamera);
    if (camera.getFacing() == CameraFacing.FRONT) {
      Log.i(TAG, "Compensating rotation for front camera");
      cwNeededRotation = (360 - cwRotationFromDisplayToCamera) % 360;
    } else {
      cwNeededRotation = cwRotationFromDisplayToCamera;
    }
    Log.i(TAG, "Clockwise rotation from display to camera: " + cwNeededRotation);
 
    Point theScreenResolution = new Point();
    display.getSize(theScreenResolution);
    screenResolution = theScreenResolution;
    Log.i(TAG, "Screen resolution in current orientation: " + screenResolution);
    // 调整图片拉伸
    Point screenResolutionForCamera = new Point();
    screenResolutionForCamera.x = screenResolution.x;
    screenResolutionForCamera.y = screenResolution.y;
    if (screenResolution.x < screenResolution.y) {
      screenResolutionForCamera.x = screenResolution.y;
      screenResolutionForCamera.y = screenResolution.x;
    }
    cameraResolution = CameraConfigurationUtils.findBestPreviewSizeValue(parameters, screenResolutionForCamera);
    Log.i(TAG, "Camera resolution: " + cameraResolution);
    bestPreviewSize = CameraConfigurationUtils.findBestPreviewSizeValue(parameters, screenResolutionForCamera);
    Log.i(TAG, "Best available preview size: " + bestPreviewSize);
 
    boolean isScreenPortrait = screenResolution.x < screenResolution.y;
    boolean isPreviewSizePortrait = bestPreviewSize.x < bestPreviewSize.y;
 
    if (isScreenPortrait == isPreviewSizePortrait) {
      previewSizeOnScreen = bestPreviewSize;
    } else {
      previewSizeOnScreen = new Point(bestPreviewSize.y, bestPreviewSize.x);
    }
    Log.i(TAG, "Preview size on screen: " + previewSizeOnScreen);
  }

本文提及的所有角度都是顺时针方向旋转

首先,display.getRotation() 返回的值为当前手机的空间位置与手机自然朝向之间的夹角,于是我们拿到了从手机自然朝向向当前手机方位的旋转角度cwRotationFromNaturalToDisplay;
其次,camera.getOrientation()得到了相机的物理方向cwRotationFromNaturalToCamera;
接下来,camera.getFacing() == CameraFacing.FRONT如果是前置相机,需要反转cwRotationFromNaturalToCamera;
计算相机到显示屏的旋转角度:cwRotationFromDisplayToCamera

   cwRotationFromDisplayToCamera =
        (360 + cwRotationFromNaturalToCamera - cwRotationFromNaturalToDisplay) % 360;

如果是前置相机,还需要反转回来;
看起来上一篇文章中,强制竖屏setDisplayOrientation(90)根本就是多此一举……上日志对照看,也许会更清晰:

  CameraConfiguration: Display at: 0
  CameraConfiguration: Camera at: 270
  CameraConfiguration: Final display orientation: 270
  CameraConfiguration: Clockwise rotation from display to camera: 270
  ...

可以看到,应该是旋转270°,结果被强制旋转了90°,把这里改回cwNeededRotation就ok了;
再来看看普通Camera的日志:

  CameraConfiguration: Display at: 0
  CameraConfiguration: Camera at: 90
  CameraConfiguration: Final display orientation: 90
  CameraConfiguration: Clockwise rotation from display to camera: 90
  ...

手机在Portrait方向夹角为0°,back相机物理方向为顺时针90°,计算出相机旋转角度为顺时针90°。

总结:用“六月里的债还得快”来比如上一篇文章,真是贴切,那个90°以及横竖屏旋转,都是没有仔细深入思考的结果;
另外效率低的问题,由于是消息传递的原因,导致这个问题也很隐蔽,只有亲自调试后才能认识到这点;
还算是有所收获!