(一百五十三)Android P WiFi连接过程中Preference的summary更新

1.前言

在WiFiSettings界面连接WiFi的时候会看到WiFi Preference的summary状态会从正在连接切换到获取ip再切换到已连接,那这状态更新流程是怎么实现的呢?

 

2.流程梳理

2.1 Settings界面更新触发

Settings界面更新

    /**
     * Called to indicate the list of AccessPoints has been updated and
     * getAccessPoints should be called to get the latest information.
     */
    @Override
    public void onAccessPointsChanged() {
        Log.d(TAG, "onAccessPointsChanged (WifiTracker) callback initiated");
        updateAccessPointsDelayed();
    }


    /**
     * Updates access points from {@link WifiManager#getScanResults()}. Adds a delay to have
     * progress bar displayed before starting to modify APs.
     */
    private void updateAccessPointsDelayed() {
        // Safeguard from some delayed event handling
        if (getActivity() != null && !mIsRestricted && mWifiManager.isWifiEnabled()) {
            final View view = getView();
            final Handler handler = view.getHandler();
            if (handler != null && handler.hasCallbacks(mUpdateAccessPointsRunnable)) {
                return;
            }
            setProgressBarVisible(true);
            view.postDelayed(mUpdateAccessPointsRunnable, 300 /* delay milliseconds */);
        }
    }


    private final Runnable mUpdateAccessPointsRunnable = () -> {
        updateAccessPointPreferences();
    };


    private void updateAccessPointPreferences() {
        // in case state has changed
        if (!mWifiManager.isWifiEnabled()) {
            return;
        }
        // AccessPoints are sorted by the WifiTracker
        final List accessPoints = mWifiTracker.getAccessPoints();
        if (isVerboseLoggingEnabled()) {
            Log.i(TAG, "updateAccessPoints called for: " + accessPoints);
        }

        boolean hasAvailableAccessPoints = false;
        mAccessPointsPreferenceCategory.removePreference(mStatusMessagePreference);
        cacheRemoveAllPrefs(mAccessPointsPreferenceCategory);

        int index =
                configureConnectedAccessPointPreferenceCategory(accessPoints) ? 1 : 0;
        int numAccessPoints = accessPoints.size();
        for (; index < numAccessPoints; index++) {
            AccessPoint accessPoint = accessPoints.get(index);
            // Ignore access points that are out of range.
            if (accessPoint.isReachable()) {
                String key = accessPoint.getKey();
                hasAvailableAccessPoints = true;
                LongPressAccessPointPreference pref =
                        (LongPressAccessPointPreference) getCachedPreference(key);
                if (pref != null) {
                    pref.setOrder(index);
                    continue;
                }
                LongPressAccessPointPreference preference =
                        createLongPressAccessPointPreference(accessPoint);
                preference.setKey(key);
                preference.setOrder(index);
                if (mOpenSsid != null && mOpenSsid.equals(accessPoint.getSsidStr())
                        && accessPoint.getSecurity() != AccessPoint.SECURITY_NONE) {
                    if (!accessPoint.isSaved() || isDisabledByWrongPassword(accessPoint)) {
                        onPreferenceTreeClick(preference);
                        mOpenSsid = null;
                    }
                }
                mAccessPointsPreferenceCategory.addPreference(preference);
                accessPoint.setListener(WifiSettings.this);
                preference.refresh();
            }
        }
        removeCachedPrefs(mAccessPointsPreferenceCategory);
        mAddPreference.setOrder(index);
        mAccessPointsPreferenceCategory.addPreference(mAddPreference);
        setAdditionalSettingsSummaries();

        if (!hasAvailableAccessPoints) {
            setProgressBarVisible(true);
            Preference pref = new Preference(getPrefContext());
            pref.setSelectable(false);
            pref.setSummary(R.string.wifi_empty_list_wifi_on);
            pref.setOrder(index++);
            pref.setKey(PREF_KEY_EMPTY_WIFI_LIST);
            mAccessPointsPreferenceCategory.addPreference(pref);
        } else {
            // Continuing showing progress bar for an additional delay to overlap with animation
            getView().postDelayed(mHideProgressBarRunnable, 1700 /* delay millis */);
        }
    }

可以看到Settings的界面更新是由于调用到onAccessPointsChanged触发的,那看下是什么调用到onAccessPointsChanged的。

 

2.2 WifiTracker

    /**
     * Invokes {@link WifiListenerExecutor#onAccessPointsChanged()} iif {@link #mStaleScanResults}
     * is false.
     */
    private void conditionallyNotifyListeners() {
        if (mStaleScanResults) {
            return;
        }

        mListener.onAccessPointsChanged();
    }

2.2.1 fetchScansAndConfigsAndUpdateAccessPoints

    /**
     * Retrieves latest scan results and wifi configs, then calls
     * {@link #updateAccessPoints(List, List)}.
     */
    private void fetchScansAndConfigsAndUpdateAccessPoints() {
        final List newScanResults = mWifiManager.getScanResults();
        if (isVerboseLoggingEnabled()) {
            Log.i(TAG, "Fetched scan results: " + newScanResults);
        }

        List configs = mWifiManager.getConfiguredNetworks();
        updateAccessPoints(newScanResults, configs);
    }

    /** Update the internal list of access points. */
    private void updateAccessPoints(final List newScanResults,
            List configs) {

        // Map configs and scan results necessary to make AccessPoints
        final Map configsByKey = new ArrayMap(configs.size());
        if (configs != null) {
            for (WifiConfiguration config : configs) {
                configsByKey.put(AccessPoint.getKey(config), config);
            }
        }
        ArrayMap> scanResultsByApKey =
                updateScanResultCache(newScanResults);

        WifiConfiguration connectionConfig = null;
        if (mLastInfo != null) {
            connectionConfig = getWifiConfigurationForNetworkId(mLastInfo.getNetworkId(), configs);
        }

        // Rather than dropping and reacquiring the lock multiple times in this method, we lock
        // once for efficiency of lock acquisition time and readability
        synchronized (mLock) {
            // Swap the current access points into a cached list for maintaining AP listeners
            List cachedAccessPoints;
            cachedAccessPoints = new ArrayList<>(mInternalAccessPoints);

            ArrayList accessPoints = new ArrayList<>();

            final List scoresToRequest = new ArrayList<>();

            for (Map.Entry> entry : scanResultsByApKey.entrySet()) {
                for (ScanResult result : entry.getValue()) {
                    NetworkKey key = NetworkKey.createFromScanResult(result);
                    if (key != null && !mRequestedScores.contains(key)) {
                        scoresToRequest.add(key);
                    }
                }

                AccessPoint accessPoint =
                        getCachedOrCreate(entry.getValue(), cachedAccessPoints);
                if (mLastInfo != null && mLastNetworkInfo != null) {
                    accessPoint.update(connectionConfig, mLastInfo, mLastNetworkInfo);
                }

                // Update the matching config if there is one, to populate saved network info
                accessPoint.update(configsByKey.get(entry.getKey()));

                accessPoints.add(accessPoint);
            }

            // If there were no scan results, create an AP for the currently connected network (if
            // it exists).
            // TODO(b/b/73076869): Add support for passpoint (ephemeral) networks
            if (accessPoints.isEmpty() && connectionConfig != null) {
                AccessPoint activeAp = new AccessPoint(mContext, connectionConfig);
                activeAp.update(connectionConfig, mLastInfo, mLastNetworkInfo);
                accessPoints.add(activeAp);
                scoresToRequest.add(NetworkKey.createFromWifiInfo(mLastInfo));
            }

            requestScoresForNetworkKeys(scoresToRequest);
            for (AccessPoint ap : accessPoints) {
                ap.update(mScoreCache, mNetworkScoringUiEnabled, mMaxSpeedLabelScoreCacheAge);
            }

            // Pre-sort accessPoints to speed preference insertion
            Collections.sort(accessPoints);

            // Log accesspoints that are being removed
            if (DBG()) {
                Log.d(TAG, "------ Dumping SSIDs that were not seen on this scan ------");
                for (AccessPoint prevAccessPoint : mInternalAccessPoints) {
                    if (prevAccessPoint.getSsid() == null)
                        continue;
                    String prevSsid = prevAccessPoint.getSsidStr();
                    boolean found = false;
                    for (AccessPoint newAccessPoint : accessPoints) {
                        if (newAccessPoint.getSsidStr() != null && newAccessPoint.getSsidStr()
                                .equals(prevSsid)) {
                            found = true;
                            break;
                        }
                    }
                    if (!found)
                        Log.d(TAG, "Did not find " + prevSsid + " in this scan");
                }
                Log.d(TAG, "---- Done dumping SSIDs that were not seen on this scan ----");
            }

            mInternalAccessPoints.clear();
            mInternalAccessPoints.addAll(accessPoints);
        }

        conditionallyNotifyListeners();
    }

看下哪边会调用

            } else if (WifiManager.SCAN_RESULTS_AVAILABLE_ACTION.equals(action)) {
                mStaleScanResults = false;

                fetchScansAndConfigsAndUpdateAccessPoints();
            } else if (WifiManager.CONFIGURED_NETWORKS_CHANGED_ACTION.equals(action)
                    || WifiManager.LINK_CONFIGURATION_CHANGED_ACTION.equals(action)) {
                fetchScansAndConfigsAndUpdateAccessPoints();
            } else if (WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(action)) {
                // TODO(sghuman): Refactor these methods so they cannot result in duplicate
                // onAccessPointsChanged updates being called from this intent.
                NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
                updateNetworkInfo(info);
                fetchScansAndConfigsAndUpdateAccessPoints();
            }




    /**
     * Synchronously update the list of access points with the latest information.
     *
     * 

Intended to only be invoked within {@link #onStart()}. */ @MainThread private void forceUpdate() { mLastInfo = mWifiManager.getConnectionInfo(); mLastNetworkInfo = mConnectivityManager.getNetworkInfo(mWifiManager.getCurrentNetwork()); fetchScansAndConfigsAndUpdateAccessPoints(); } /** * Start tracking wifi networks and scores. * *

Registers listeners and starts scanning for wifi networks. If this is not called * then forceUpdate() must be called to populate getAccessPoints(). */ @Override @MainThread public void onStart() { // fetch current ScanResults instead of waiting for broadcast of fresh results forceUpdate();

总结一下

  • wifiSettings onStart回调需要更新界面的时候
  • 接收到WifiManager.SCAN_RESULTS_AVAILABLE_ACTION
  • 接收到WifiManager.CONFIGURED_NETWORKS_CHANGED_ACTION
  • 接收到WifiManager.LINK_CONFIGURATION_CHANGED_ACTION
  • 接收到WifiManager.NETWORK_STATE_CHANGED_ACTION,我们连接过程中的网络变化对应于这个广播

 

2.2.2 updateNetworkInfo

    private void updateNetworkInfo(NetworkInfo networkInfo) {

        /* Sticky broadcasts can call this when wifi is disabled */
        if (!mWifiManager.isWifiEnabled()) {
            clearAccessPointsAndConditionallyUpdate();
            return;
        }

        if (networkInfo != null) {
            mLastNetworkInfo = networkInfo;
            if (DBG()) {
                Log.d(TAG, "mLastNetworkInfo set: " + mLastNetworkInfo);
            }

            if(networkInfo.isConnected() != mConnected.getAndSet(networkInfo.isConnected())) {
                mListener.onConnectedChanged();
            }
        }

        WifiConfiguration connectionConfig = null;

        mLastInfo = mWifiManager.getConnectionInfo();
        if (DBG()) {
            Log.d(TAG, "mLastInfo set as: " + mLastInfo);
        }
        if (mLastInfo != null) {
            connectionConfig = getWifiConfigurationForNetworkId(mLastInfo.getNetworkId(),
                    mWifiManager.getConfiguredNetworks());
        }

        boolean updated = false;
        boolean reorder = false; // Only reorder if connected AP was changed

        synchronized (mLock) {
            for (int i = mInternalAccessPoints.size() - 1; i >= 0; --i) {
                AccessPoint ap = mInternalAccessPoints.get(i);
                boolean previouslyConnected = ap.isActive();
                if (ap.update(connectionConfig, mLastInfo, mLastNetworkInfo)) {
                    updated = true;
                    if (previouslyConnected != ap.isActive()) reorder = true;
                }
                if (ap.update(mScoreCache, mNetworkScoringUiEnabled, mMaxSpeedLabelScoreCacheAge)) {
                    reorder = true;
                    updated = true;
                }
            }

            if (reorder) {
                Collections.sort(mInternalAccessPoints);
            }
            if (updated) {
                conditionallyNotifyListeners();
            }
        }
    }


else if (WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(action)) {
                // TODO(sghuman): Refactor these methods so they cannot result in duplicate
                // onAccessPointsChanged updates being called from this intent.
                NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
                updateNetworkInfo(info);
                fetchScansAndConfigsAndUpdateAccessPoints();
            } else if (WifiManager.RSSI_CHANGED_ACTION.equals(action)) {
                NetworkInfo info =
                        mConnectivityManager.getNetworkInfo(mWifiManager.getCurrentNetwork());
                updateNetworkInfo(info);
            }

总结一下

  • WifiManager.NETWORK_STATE_CHANGED_ACTION
  • WifiManager.RSSI_CHANGED_ACTION

 

2.2.3 clearAccessPointsAndConditionallyUpdate

    /**
     * Clears the access point list and conditionally invokes
     * {@link WifiListener#onAccessPointsChanged()} if required (i.e. the list was not already
     * empty).
     */
    private void clearAccessPointsAndConditionallyUpdate() {
        synchronized (mLock) {
            if (!mInternalAccessPoints.isEmpty()) {
                mInternalAccessPoints.clear();
                conditionallyNotifyListeners();
            }
        }
    }

看起来是WiFi关闭的时候更新下界面

 

2.2.4  updateNetworkScores

    /**
     * Update all the internal access points rankingScores, badge and metering.
     *
     * 

Will trigger a resort and notify listeners of changes if applicable. * *

Synchronized on {@link #mLock}. */ private void updateNetworkScores() { synchronized (mLock) { boolean updated = false; for (int i = 0; i < mInternalAccessPoints.size(); i++) { if (mInternalAccessPoints.get(i).update( mScoreCache, mNetworkScoringUiEnabled, mMaxSpeedLabelScoreCacheAge)) { updated = true; } } if (updated) { Collections.sort(mInternalAccessPoints); conditionallyNotifyListeners(); } } } /** * Sanity warning: this wipes out mScoreCache, so use with extreme caution * @param workThread substitute Handler thread, for testing purposes only */ @VisibleForTesting // TODO(sghuman): Remove this method, this needs to happen in a factory method and be passed in // during construction void setWorkThread(HandlerThread workThread) { mWorkThread = workThread; mWorkHandler = new Handler(workThread.getLooper()); mScoreCache = new WifiNetworkScoreCache(mContext, new CacheListener(mWorkHandler) { @Override public void networkCacheUpdated(List networks) { if (!mRegistered) return; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Score cache was updated with networks: " + networks); } updateNetworkScores(); } }); }

这scores wifiStateMachine中也有相关代码,后续梳理下。

 

2.3 summary由来

    private void updateAccessPointPreferences() {
        // in case state has changed
        if (!mWifiManager.isWifiEnabled()) {
            return;
        }
        // AccessPoints are sorted by the WifiTracker
        final List accessPoints = mWifiTracker.getAccessPoints();
        if (isVerboseLoggingEnabled()) {
            Log.i(TAG, "updateAccessPoints called for: " + accessPoints);
        }

        boolean hasAvailableAccessPoints = false;
        mAccessPointsPreferenceCategory.removePreference(mStatusMessagePreference);
        cacheRemoveAllPrefs(mAccessPointsPreferenceCategory);

        int index =
                configureConnectedAccessPointPreferenceCategory(accessPoints) ? 1 : 0;
        int numAccessPoints = accessPoints.size();
        for (; index < numAccessPoints; index++) {
            AccessPoint accessPoint = accessPoints.get(index);
            // Ignore access points that are out of range.
            if (accessPoint.isReachable()) {
                String key = accessPoint.getKey();
                hasAvailableAccessPoints = true;
                LongPressAccessPointPreference pref =
                        (LongPressAccessPointPreference) getCachedPreference(key);
                if (pref != null) {
                    pref.setOrder(index);
                    continue;
                }
                LongPressAccessPointPreference preference =
                        createLongPressAccessPointPreference(accessPoint);
                preference.setKey(key);
                preference.setOrder(index);
                if (mOpenSsid != null && mOpenSsid.equals(accessPoint.getSsidStr())
                        && accessPoint.getSecurity() != AccessPoint.SECURITY_NONE) {
                    if (!accessPoint.isSaved() || isDisabledByWrongPassword(accessPoint)) {
                        onPreferenceTreeClick(preference);
                        mOpenSsid = null;
                    }
                }
                mAccessPointsPreferenceCategory.addPreference(preference);
                accessPoint.setListener(WifiSettings.this);
                preference.refresh();
...
            }
        }
...
    }

    @NonNull
    private LongPressAccessPointPreference createLongPressAccessPointPreference(
            AccessPoint accessPoint) {
        return new LongPressAccessPointPreference(accessPoint, getPrefContext(), mUserBadgeCache,
                false /* forSavedNetworks */, R.drawable.ic_wifi_signal_0, this);
    }

触发界面更新的时候会调用AccessPointPreference的refresh()更新Preference

AccessPointPreference.java

    /**
     * Updates the title and summary; may indirectly call notifyChanged().
     */
    public void refresh() {
        setTitle(this, mAccessPoint, mForSavedNetworks);
        final Context context = getContext();
        int level = mAccessPoint.getLevel();
        int wifiSpeed = mAccessPoint.getSpeed();
        if (level != mLevel || wifiSpeed != mWifiSpeed) {
            mLevel = level;
            mWifiSpeed = wifiSpeed;
            updateIcon(mLevel, context);
            notifyChanged();
        }

        updateBadge(context);

        setSummary(mForSavedNetworks ? mAccessPoint.getSavedNetworkSummary()
                : mAccessPoint.getSettingsSummary());

        mContentDescription = buildContentDescription(getContext(), this /* pref */, mAccessPoint);
    }

从这边可以看到summary是从accessPoint中获取的。forSavedNetworks是表示是否是已保存网络界面,WifiSettings中不是,所以调用的是AccessPoint的getSettingsSummary

    private String getSettingsSummary(WifiConfiguration config) {
        // Update to new summary
        StringBuilder summary = new StringBuilder();

        if (isActive() && config != null && config.isPasspoint()) {
            // This is the active connection on passpoint
            summary.append(getSummary(mContext, getDetailedState(),
                    false, config.providerFriendlyName));
        } else if (isActive() && config != null && getDetailedState() == DetailedState.CONNECTED
                && mIsCarrierAp) {
            summary.append(String.format(mContext.getString(R.string.connected_via_carrier), mCarrierName));
        } else if (isActive()) {
            // This is the active connection on non-passpoint network
            summary.append(getSummary(mContext, getDetailedState(),
                    mInfo != null && mInfo.isEphemeral()));
        } else if (config != null && config.isPasspoint()
                && config.getNetworkSelectionStatus().isNetworkEnabled()) {
            String format = mContext.getString(R.string.available_via_passpoint);
            summary.append(String.format(format, config.providerFriendlyName));
        } else if (config != null && config.hasNoInternetAccess()) {
            int messageID = config.getNetworkSelectionStatus().isNetworkPermanentlyDisabled()
                    ? R.string.wifi_no_internet_no_reconnect
                    : R.string.wifi_no_internet;
            summary.append(mContext.getString(messageID));
        } else if (config != null && !config.getNetworkSelectionStatus().isNetworkEnabled()) {
            WifiConfiguration.NetworkSelectionStatus networkStatus =
                    config.getNetworkSelectionStatus();
            switch (networkStatus.getNetworkSelectionDisableReason()) {
                case WifiConfiguration.NetworkSelectionStatus.DISABLED_AUTHENTICATION_FAILURE:
                    summary.append(mContext.getString(R.string.wifi_disabled_password_failure));
                    break;
                case WifiConfiguration.NetworkSelectionStatus.DISABLED_BY_WRONG_PASSWORD:
                    summary.append(mContext.getString(R.string.wifi_check_password_try_again));
                    break;
                case WifiConfiguration.NetworkSelectionStatus.DISABLED_DHCP_FAILURE:
                case WifiConfiguration.NetworkSelectionStatus.DISABLED_DNS_FAILURE:
                    summary.append(mContext.getString(R.string.wifi_disabled_network_failure));
                    break;
                case WifiConfiguration.NetworkSelectionStatus.DISABLED_ASSOCIATION_REJECTION:
                    summary.append(mContext.getString(R.string.wifi_disabled_generic));
                    break;
            }
        } else if (config != null && config.getNetworkSelectionStatus().isNotRecommended()) {
            summary.append(mContext.getString(R.string.wifi_disabled_by_recommendation_provider));
        } else if (mIsCarrierAp) {
            summary.append(String.format(mContext.getString(R.string.available_via_carrier), mCarrierName));
        } else if (!isReachable()) { // Wifi out of range
            summary.append(mContext.getString(R.string.wifi_not_in_range));
        } else { // In range, not disabled.
            if (config != null) { // Is saved network
                // Last attempt to connect to this failed. Show reason why
                switch (config.recentFailure.getAssociationStatus()) {
                    case WifiConfiguration.RecentFailure.STATUS_AP_UNABLE_TO_HANDLE_NEW_STA:
                        summary.append(mContext.getString(
                                R.string.wifi_ap_unable_to_handle_new_sta));
                        break;
                    default:
                        // "Saved"
                        summary.append(mContext.getString(R.string.wifi_remembered));
                        break;
                }
            }
        }

        if (isVerboseLoggingEnabled()) {
            summary.append(WifiUtils.buildLoggingSummary(this, config));
        }

        if (config != null && (WifiUtils.isMeteredOverridden(config) || config.meteredHint)) {
            return mContext.getResources().getString(
                    R.string.preference_summary_default_combination,
                    WifiUtils.getMeteredLabel(mContext, config),
                    summary.toString());
        }

        // If Speed label and summary are both present, use the preference combination to combine
        // the two, else return the non-null one.
        if (getSpeedLabel() != null && summary.length() != 0) {
            return mContext.getResources().getString(
                    R.string.preference_summary_default_combination,
                    getSpeedLabel(),
                    summary.toString());
        } else if (getSpeedLabel() != null) {
            return getSpeedLabel();
        } else {
            return summary.toString();
        }
    }

最常见的wpa/wpa2 psk应该是走到下面的if里面

 else if (isActive()) {
            // This is the active connection on non-passpoint network
            summary.append(getSummary(mContext, getDetailedState(),
                    mInfo != null && mInfo.isEphemeral()));

    /**
     * Return whether this is the active connection.
     * For ephemeral connections (networkId is invalid), this returns false if the network is
     * disconnected.
     */
    public boolean isActive() {
        return mNetworkInfo != null &&
                (networkId != WifiConfiguration.INVALID_NETWORK_ID ||
                 mNetworkInfo.getState() != State.DISCONNECTED);
    }


    public DetailedState getDetailedState() {
        if (mNetworkInfo != null) {
            return mNetworkInfo.getDetailedState();
        }
        Log.w(TAG, "NetworkInfo is null, cannot return detailed state");
        return null;
    }

看到这边就知道了NetworkInfo是WifiStateMachine连接过程中状态机变化发出来的广播带来的网络相关信息,之前其实有看到,WifiTracker有监听WifiManager.NETWORK_STATE_CHANGED_ACTION,其中带有了WiFi 连接状态的消息,会更新到NetworkInfo中,所以Settings summary显示的其实是WifiStateMachine发来的状态消息

WifiTracker

 else if (WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(action)) {
                // TODO(sghuman): Refactor these methods so they cannot result in duplicate
                // onAccessPointsChanged updates being called from this intent.
                NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
                updateNetworkInfo(info);
                fetchScansAndConfigsAndUpdateAccessPoints();
            } 

。。。
                if (mLastInfo != null && mLastNetworkInfo != null) {
                    accessPoint.update(connectionConfig, mLastInfo, mLastNetworkInfo);
                }

 

3.总结

WiFi连接过程中Preference的summary更新本质上就是将WiFi连接过程中的信息从WifiStateMachine通过广播传递到WifiTracker,之后触发界面更新,界面更新会从广播带来的NetworkInfo中取出状态相关信息并显示出来。

 

你可能感兴趣的:(Wifi)