省电 + phone-call-intent => 没有互联网?

Battery-saver + phone-call-intent => no Internet?

注:事实证明,原题的假设是不正确的。在底部查看有关其编辑的更多详细信息。

现在是关于节电模式,而不是节电和打瞌睡模式。它也与 Service&BroadcastReceiver 无关,而只是 BroadcastReceiver 本身。

背景

从 Android Lollipop 开始,Google 引入了新的手动和自动方式来帮助节省电池电量:

"Doze"模式,"Battery-saver".

在某些情况下,由于这些技术,应用程序可能无法访问互联网。

问题

我开发的应用程序需要使用在特定情况下触发的后台服务访问互联网,如果收到重要信息,它会显示一些 UI。

作为用户,我注意到在某些情况下,它无法访问 Internet。

检测app是否可以上网是这样的:

public static boolean isInternetOn(Context context) {
    final NetworkInfo info = ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)).getActiveNetworkInfo();
    return !(info == null || !info.isConnectedOrConnecting());
}

问题是我需要检查为什么这有时 returns 错误,所以如果它失败了,我们应该告诉用户(可能通过通知)数据无法访问,因为设备限制应用程序,并让用户将应用程序列入电池优化白名单。

我不确定哪些因素会对此产生影响:打瞌睡、节电模式或两者兼而有之,如果始终如此,对于所有设备,在所有情况下。

我试过的

我找到的是如何查询Doze mode和Battery-saver(省电)模式:

public class PowerSaverHelper {
    public enum PowerSaveState {
        ON, OFF, ERROR_GETTING_STATE, IRRELEVANT_OLD_ANDROID_API
    }

    public enum WhiteListedInBatteryOptimizations {
        WHITE_LISTED, NOT_WHITE_LISTED, ERROR_GETTING_STATE, IRRELEVANT_OLD_ANDROID_API
    }

    public enum DozeState {
        NORMAL_INTERACTIVE, DOZE_TURNED_ON_IDLE, NORMAL_NON_INTERACTIVE, ERROR_GETTING_STATE, IRRELEVANT_OLD_ANDROID_API
    }

    @NonNull
    public static DozeState getDozeState(@NonNull Context context) {
        if (VERSION.SDK_INT < VERSION_CODES.M)
            return DozeState.IRRELEVANT_OLD_ANDROID_API;
        final PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (pm == null)
            return DozeState.ERROR_GETTING_STATE;
        return pm.isDeviceIdleMode() ? DozeState.DOZE_TURNED_ON_IDLE : pm.isInteractive() ? DozeState.NORMAL_INTERACTIVE : DozeState.NORMAL_NON_INTERACTIVE;
    }

    @NonNull
    public static PowerSaveState getPowerSaveState(@NonNull Context context) {
        if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP)
            return PowerSaveState.IRRELEVANT_OLD_ANDROID_API;
        final PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (pm == null)
            return PowerSaveState.ERROR_GETTING_STATE;
        return pm.isPowerSaveMode() ? PowerSaveState.ON : PowerSaveState.OFF;
    }


    @NonNull
    public static WhiteListedInBatteryOptimizations getIfAppIsWhiteListedFromBatteryOptimizations(@NonNull Context context, @NonNull String packageName) {
        if (VERSION.SDK_INT < VERSION_CODES.M)
            return WhiteListedInBatteryOptimizations.IRRELEVANT_OLD_ANDROID_API;
        final PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (pm == null)
            return WhiteListedInBatteryOptimizations.ERROR_GETTING_STATE;
        return pm.isIgnoringBatteryOptimizations(packageName) ? WhiteListedInBatteryOptimizations.WHITE_LISTED : WhiteListedInBatteryOptimizations.NOT_WHITE_LISTED;
    }

    //@TargetApi(VERSION_CODES.M)
    @SuppressLint("BatteryLife")
    @RequiresPermission(permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
    @Nullable
    public static Intent prepareIntentForWhiteListingOfBatteryOptimization(@NonNull Context context, @NonNull String packageName, boolean alsoWhenWhiteListed) {
        if (VERSION.SDK_INT < VERSION_CODES.M)
            return null;
        if (ContextCompat.checkSelfPermission(context, permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) == PackageManager.PERMISSION_DENIED)
            return null;
        final WhiteListedInBatteryOptimizations appIsWhiteListedFromPowerSave = getIfAppIsWhiteListedFromBatteryOptimizations(context, packageName);
        Intent intent = null;
        switch (appIsWhiteListedFromPowerSave) {
            case WHITE_LISTED:
                if (alsoWhenWhiteListed)
                    intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
                break;
            case NOT_WHITE_LISTED:
                intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).setData(Uri.parse("package:" + packageName));
                break;
            case ERROR_GETTING_STATE:
            case IRRELEVANT_OLD_ANDROID_API:
            default:
                break;
        }
        return intent;
    }

    /**
     * registers a receiver to listen to power-save events. returns true iff succeeded to register the broadcastReceiver.
     */
    @TargetApi(VERSION_CODES.M)
    public static boolean registerPowerSaveReceiver(@NonNull Context context, @NonNull BroadcastReceiver receiver) {
        if (VERSION.SDK_INT < VERSION_CODES.M)
            return false;
        IntentFilter filter = new IntentFilter();
        filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED);
        context.registerReceiver(receiver, filter);
        return true;
    }

}

我想我也找到了一种在连接到设备时检查它们的方法:

省电模式:

./adb shell settings put global low_power [1|0]

打瞌睡状态:

./adb shell dumpsys deviceidle step [light|deep]

并且:

./adb shell dumpsys deviceidle force-idle

问题

简而言之,我只想知道无法访问 Internet 的原因是否确实是因为没有 Internet 连接,或者应用程序是否由于某些电池优化而当前受到限制。

只有在被限制的情况下,我可以警告用户,如果他同意,该应用程序将被列入白名单,以便它仍然可以正常工作。

这是我的问题:

  1. 以上哪项阻止了应用程序的后台服务访问互联网?都是这些引起的吗?它是特定于设备的吗? "Interactive"有影响吗?

  2. 如果已经有办法进入"light"和"deep"打瞌睡状态,"force-idle"有什么用?还有一种方法可以将打瞌睡模式重置为正常吗?我尝试了多个命令,但只有重新启动设备才真正让它恢复正常...

  3. 我创建的 BroadcastReceiver 是否允许正确检查?它是否会在所有情况下触发因所有特殊情况而拒绝访问 Internet?是不是真的不能在manifest里注册?

  4. 是否可以检查无法访问互联网的原因是否确实是因为没有互联网连接,或者应用程序是否由于某些电池优化而当前被限制?

  5. AndroidO 是否更改了特殊情况下后台服务的互联网连接限制?也许我应该检查更多案例?

  6. 假设我在前台将服务更改为 运行(带有通知),这是否涵盖所有情况,并且始终可以访问 Internet,无论设备处于何种特殊状态在?


编辑:这似乎根本不是服务的错,它也发生在节电模式下,没有打盹模式。

该服务的触发器是侦听 phone 调用事件的 BroadcastReceiver,即使我在其 onReceive 功能上检查互联网连接,我也看到它 returns错误的。从它启动的服务也是如此,即使它是前台服务。查看 NetworkInfo 结果,它是 "BLOCKED",它的状态确实是 "DISCONNECTED".

现在的问题是,为什么会出现这种情况。

这里有一个新的示例 POC 来验证这一点。要重现,您需要打开省电模式(使用 ./adb shell settings put global low_power 1 命令,或作为用户),然后启动它,接受权限,关闭 activity,然后从另一个 phone 调用到这个。您会注意到在 activity 上它显示有 Internet 连接,而在 BroadcastReceiver 上却显示没有。

请注意,当连接到 USB 数据线时,省电模式可能会自动关闭,因此您可能需要在未连接设备时尝试。使用 adb 命令可以阻止它,这与启用它的用户方法相反。

示例项目也可以找到 here,尽管它最初是关于打瞌睡模式的。只需改用节电模式,即可查看问题是否发生。

PhoneBroadcastReceiver

public class PhoneBroadcastReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(final Context context, final Intent intent) {
        Log.d("AppLog", "PhoneBroadcastReceiver:isInternetOn:" + isInternetOn(context));
    }

    public static boolean isInternetOn(Context context) {
        final NetworkInfo info = ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)).getActiveNetworkInfo();
        return !(info == null || !info.isConnectedOrConnecting());
    }
}

清单

<manifest package="com.example.user.myapplication" xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.READ_PHONE_STATE"/>

    <application
        android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <receiver android:name=".PhoneBroadcastReceiver">
            <intent-filter >
                <action android:name="android.intent.action.PHONE_STATE"/>
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.NEW_OUTGOING_CALL"/>
            </intent-filter>
        </receiver>
    </application>

</manifest>

MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Log.d("AppLog", "MainActivity: isInternetOn:" + PhoneBroadcastReceiver.isInternetOn(this));
        if (VERSION.SDK_INT >= VERSION_CODES.M) {
            requestPermissions(new String[]{permission.READ_PHONE_STATE, permission.PROCESS_OUTGOING_CALLS}, 1);
        }
    }
}

Google 推荐的处理方式是使用 JobScheduler, or similar library (e.g. Firebase JobDispatcher), to schedule the job when there's network during one of the "maintenance windows". See Optimizing for Doze and App Standby 获取更多详细信息。如果你真的需要在维护之外执行你的服务window,你应该将信息存储在磁盘(数据库,文件...)上并定期同步它,或者在极端情况下请求加入白名单。

说完这些,我们开始回答您的问题。

Which of the above prevent background services of apps to access the Internet? Do all of them cause it? Is it device-specific? Does "Interactive" affect it?

肯定是打瞌睡模式。电池省电模式,它表示 "Limits ... most background data",但我认为这只是对应用程序的建议。参见 here

具体注意:

RESTRICT_BACKGROUND_STATUS_ENABLED The user has enabled Data Saver for this app. Apps should make an effort to limit data usage in the foreground and gracefully handle restrictions to background data usage.

因此,它看起来像是一个建议,而不是强制执行的内容。

另外,请注意一些 phone 制造商有省电应用程序,这些应用程序可能会施加额外的限制并终止应用程序以节省电池电量。见下文answer and discussion.

What's "force-idle" for, if there is already a way to go to "light" and "deep" doze states? Is there also a way to reset doze mode back to normal? I tried multiple commands, but only restarting of the device really got it to reset back to normal...

除了您可能已经在 this and this 篇文章的 "Testing" 部分中阅读过的文档外,我没有其他经验可以分享。

Does the BroadcastReceiver I created allow to check it correctly? Will it trigger in all cases that access to the Internet is denied due to all of the special cases? Is it true that I can't register to it in manifest?

不完全确定您是否能够检查所有情况,但如果可能,应该尝试遵循 "synchronize when network is available" 策略。

关于在清单中注册,如果您的应用针对 Android 奥利奥,是的,you must register most of the receivers programmatically

Is it possible to check if the reason for not being able to access the Internet, is indeed because there is no Internet connection, or if the app just got currently restricted due to certain battery optimizations?

您分享的代码看起来不错,但我不希望 100% 确定,因为有时可能会同时发生多种情况。

Have the restrictions of Internet connection for background services on special cases changed on Android O ? Maybe even more cases I should check?

检查应该是一样的。与 Marshmallow 相比,Doze 模式会在更多情况下被触发,但对应用程序的影响应该完全相同。

Suppose I change the service to run in foreground (with a notification), will this cover all cases, and always have access to the Internet, no matter what special state the device is in?

正如我之前所说,在某些设备(省电应用程序)中,该应用程序将被终止,因此它可能无法运行。有现货Android,可能会涨一些限制,但我不能确定,因为我自己没有测试过。

所以我从 issue tracker 下载了您的示例应用程序,按照您描述的方式对其进行了测试,并在 Nexus 5 运行 Android 6.0.1:


测试 1 的条件:

  • 应用未列入白名单
  • 使用 adb shell settings put global low_power 1
  • 设置的省电模式
  • 设备使用 adb tcpip <port>adb connect <ip>:<port>
  • 通过无线连接
  • 只有 BroadcastReceiver,没有 Service

在此测试中,应用程序的功能与您提到的一样:

应用程序在后台 -

D/AppLog: PhoneBroadcastReceiver:isInternetOn:false
D/AppLog: PhoneBroadcastReceiver:isInternetOn:false

测试 2 的条件:

  • 与测试 1 相同,但有以下更改

  • BroadcastReceiver 启动服务(示例如下)

    public class PhoneService extends Service {
    
        public void onCreate() {
            super.onCreate();
            startForeground(1, new Notification.Builder(this)
                    .setSmallIcon(R.mipmap.ic_launcher_foreground)
                    .setContentTitle("Test title")
                    .setContentText("Test text")
                    .getNotification());
        }
    
        public int onStartCommand(Intent intent, int flags, int startId) {
            final String msg = "PhoneService:isInternetOn:" + isInternetOn(this);
            Log.d("AppLog", msg);
            Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
            return START_STICKY;
        }
    
        public static boolean isInternetOn(Context context) {
            final NetworkInfo info = ((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)).getActiveNetworkInfo();
            return !(info == null || !info.isConnectedOrConnecting());
        }
    
        @Nullable
        @Override
        public IBinder onBind(Intent intent) {
            return null;
        }
    }
    

这个测试给了我和上面一样的结果。


测试 3 的条件:

  • 与测试 2 相同,但有以下更改
  • 应用因电池优化而被列入白名单

应用程序在后台 -

D/AppLog: PhoneService:isInternetOn:false
D/AppLog: PhoneService:isInternetOn:true

这个测试很有趣,因为第一个日志没有给我互联网连接,但是第二个日志可以,这是在第一个日志之后大约 4 秒,并且在它建立前台服务之后很久。当我 运行 第二次测试时,两个日志都是真实的。这似乎表明调用 startForeground 函数与系统将应用程序置于前台之间存在延迟。


我什至 运行 使用 adb shell dumpsys deviceidle force-idle 测试 2 和 3,得到与测试 3 相似的结果,其中第一个日志未连接,但所有后续日志都显示互联网连接。

我相信这一切都按预期运行,因为设备上的节电状态:

To help improve battery life, battery saver reduces your device's performance and limits vibration, location services, and most background data. Email, messaging, and other apps that rely on syncing may not update unless you open them.

因此,除非您当前正在使用该应用程序,或者您将应用程序列入白名单 并且 有前台服务 运行,否则您可能无法使用互联网连接如果您的应用程序处于节电模式或打瞌睡模式,则可以使用它。


编辑#1

与使用某些计时器重新检查互联网连接相比,这可能是一种不同的解决方法:

MyService.java

@Override
public void onCreate() {
    super.onCreate();
    Log.d("AppLog", "MyService:onCreate isInternetOn:" + PhoneBroadcastReceiver.isInternetOn(this));
    if (!PhoneBroadcastReceiver.isInternetOn(this)) {
        if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
            final ConnectivityManager connectivityManager = (ConnectivityManager) getApplicationContext().getSystemService(CONNECTIVITY_SERVICE);
            connectivityManager.registerNetworkCallback(new NetworkRequest.Builder()
                    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                    .build(), new ConnectivityManager.NetworkCallback() {
                @Override
                public void onAvailable(Network network) {
                    //Use this network object to perform network operations
                    connectivityManager.unregisterNetworkCallback(this);
                }
            });
        }
    }
}

如果有您可以使用的网络连接,这将立即 return,或者等到有连接。如果您在一段时间后没有收到任何结果,您可能可以从这里使用 Handler 取消注册,尽管我可能只是让它保持活动状态。


编辑#2

这就是我的推荐。这是基于您之前在评论中给出的答案 (Android check internet connection):

@Override
public void onCreate() {
    super.onCreate();
    Log.d("AppLog", "MyService:onCreate isInternetOn:" + PhoneBroadcastReceiver.isInternetOn(this));
    if (!PhoneBroadcastReceiver.isInternetOn(this)) {
        if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
            final ConnectivityManager connectivityManager = (ConnectivityManager) getApplicationContext().getSystemService(CONNECTIVITY_SERVICE);
            connectivityManager.registerNetworkCallback(new NetworkRequest.Builder()
                    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                    .build(), new ConnectivityManager.NetworkCallback() {
                @Override
                public void onAvailable(Network network) {
                    isConnected(network); //Probably add this to a log output to verify this actually works for you
                    connectivityManager.unregisterNetworkCallback(this);
                }
            });
        }
    }
}

public static boolean isConnected(Network network) {
    
    if (network != null) {
        try {
            URL url = new URL("http://www.google.com/");
            HttpURLConnection urlc = (HttpURLConnection)network.openConnection(url);
            urlc.setRequestProperty("User-Agent", "test");
            urlc.setRequestProperty("Connection", "close");
            urlc.setConnectTimeout(1000); // mTimeout is in seconds
            urlc.connect();
            if (urlc.getResponseCode() == 200) {
                return true;
            } else {
                return false;
            }
        } catch (IOException e) {
            Log.i("warning", "Error checking internet connection", e);
            return false;
        }
    }

    return false;

}