Good way of getting the user's location in And

2019-01-01 07:37发布

The problem:

Getting the user's current location within a threshold ASAP and at the same time conserve battery.

Why the problem is a problem:

First off, android has two providers; network and GPS. Sometimes network is better and sometimes the GPS is better.

By "better" I mean speed vs. accuracy ratio.
I'm willing to sacrifice a few meters in accuracy if I can get the location almost instant and without turning on the GPS.

Secondly, if you request updates for location changes nothing is sent if the current location is stable.

Google has an example of determining the "best" location here: http://developer.android.com/guide/topics/location/obtaining-user-location.html#BestEstimate
But I think it's no where near as good as it should/could be.

I'm kind of confused why google hasn't a normalized API for location, the developer shouldn't have to care where the location is from, you should just specify what you want and the phone should choose for you.

What I need help with:

I need to find a good way to determine the "best" location, maybe though some heuristic or maybe through some 3rd party library.

This does not mean determine the best provider!
I'm probably gonna use all providers and picking the best of them.

Background of the app:

The app will collect the user's location at a fixed interval (let say every 10 minutes or so) and send it to a server.
The app should conserve as much battery as possible and the location should have X (50-100?) meters accuracy.

The goal is to later be able to plot the user's path during the day on a map so I need sufficient accuracy for that.

Misc:

What do you think are reasonable values on desired and accepted accuracies?
I've been using 100m as accepted and 30m as desired, is this to much to ask?
I'd like to be able to plot the user's path on a map later.
Is 100m for desired and 500m for accepted better?

Also, right now I have the GPS on for a maximum of 60 seconds per location update, is this too short to get a location if you're indoors with an accuracy of maybe 200m?


This is my current code, any feedback is appreciated (apart from the lack of error checking which is TODO):

protected void runTask() {
    final LocationManager locationManager = (LocationManager) context
            .getSystemService(Context.LOCATION_SERVICE);
    updateBestLocation(locationManager
            .getLastKnownLocation(LocationManager.GPS_PROVIDER));
    updateBestLocation(locationManager
            .getLastKnownLocation(LocationManager.NETWORK_PROVIDER));
    if (getLocationQuality(bestLocation) != LocationQuality.GOOD) {
        Looper.prepare();
        setLooper(Looper.myLooper());
        // Define a listener that responds to location updates
        LocationListener locationListener = new LocationListener() {

            public void onLocationChanged(Location location) {
                updateBestLocation(location);
                if (getLocationQuality(bestLocation) != LocationQuality.GOOD)
                    return;
                // We're done
                Looper l = getLooper();
                if (l != null) l.quit();
            }

            public void onProviderEnabled(String provider) {}

            public void onProviderDisabled(String provider) {}

            public void onStatusChanged(String provider, int status,
                    Bundle extras) {
                // TODO Auto-generated method stub
                Log.i("LocationCollector", "Fail");
                Looper l = getLooper();
                if (l != null) l.quit();
            }
        };
        // Register the listener with the Location Manager to receive
        // location updates
        locationManager.requestLocationUpdates(
                LocationManager.GPS_PROVIDER, 1000, 1, locationListener,
                Looper.myLooper());
        locationManager.requestLocationUpdates(
                LocationManager.NETWORK_PROVIDER, 1000, 1,
                locationListener, Looper.myLooper());
        Timer t = new Timer();
        t.schedule(new TimerTask() {

            @Override
            public void run() {
                Looper l = getLooper();
                if (l != null) l.quit();
                // Log.i("LocationCollector",
                // "Stopping collector due to timeout");
            }
        }, MAX_POLLING_TIME);
        Looper.loop();
        t.cancel();
        locationManager.removeUpdates(locationListener);
        setLooper(null);
    }
    if (getLocationQuality(bestLocation) != LocationQuality.BAD) 
        sendUpdate(locationToString(bestLocation));
    else Log.w("LocationCollector", "Failed to get a location");
}

private enum LocationQuality {
    BAD, ACCEPTED, GOOD;

    public String toString() {
        if (this == GOOD) return "Good";
        else if (this == ACCEPTED) return "Accepted";
        else return "Bad";
    }
}

private LocationQuality getLocationQuality(Location location) {
    if (location == null) return LocationQuality.BAD;
    if (!location.hasAccuracy()) return LocationQuality.BAD;
    long currentTime = System.currentTimeMillis();
    if (currentTime - location.getTime() < MAX_AGE
            && location.getAccuracy() <= GOOD_ACCURACY)
        return LocationQuality.GOOD;
    if (location.getAccuracy() <= ACCEPTED_ACCURACY)
        return LocationQuality.ACCEPTED;
    return LocationQuality.BAD;
}

private synchronized void updateBestLocation(Location location) {
    bestLocation = getBestLocation(location, bestLocation);
}

// Pretty much an unmodified version of googles example
protected Location getBestLocation(Location location,
        Location currentBestLocation) {
    if (currentBestLocation == null) {
        // A new location is always better than no location
        return location;
    }
    if (location == null) return currentBestLocation;
    // Check whether the new location fix is newer or older
    long timeDelta = location.getTime() - currentBestLocation.getTime();
    boolean isSignificantlyNewer = timeDelta > TWO_MINUTES;
    boolean isSignificantlyOlder = timeDelta < -TWO_MINUTES;
    boolean isNewer = timeDelta > 0;
    // If it's been more than two minutes since the current location, use
    // the new location
    // because the user has likely moved
    if (isSignificantlyNewer) {
        return location;
        // If the new location is more than two minutes older, it must be
        // worse
    } else if (isSignificantlyOlder) {
        return currentBestLocation;
    }
    // Check whether the new location fix is more or less accurate
    int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation
            .getAccuracy());
    boolean isLessAccurate = accuracyDelta > 0;
    boolean isMoreAccurate = accuracyDelta < 0;
    boolean isSignificantlyLessAccurate = accuracyDelta > 200;
    // Check if the old and new location are from the same provider
    boolean isFromSameProvider = isSameProvider(location.getProvider(),
            currentBestLocation.getProvider());
    // Determine location quality using a combination of timeliness and
    // accuracy
    if (isMoreAccurate) {
        return location;
    } else if (isNewer && !isLessAccurate) {
        return location;
    } else if (isNewer && !isSignificantlyLessAccurate
            && isFromSameProvider) {
        return location;
    }
    return bestLocation;
}

/** Checks whether two providers are the same */
private boolean isSameProvider(String provider1, String provider2) {
    if (provider1 == null) {
        return provider2 == null;
    }
    return provider1.equals(provider2);
}

12条回答
十年一品温如言
2楼-- · 2019-01-01 08:03

Location accuracy depends mostly on the location provider used:

  1. GPS - will get you several meters accuracy (assuming you have GPS reception)
  2. Wifi - Will get you few hundred meters accuracy
  3. Cell Network - Will get you very inaccurate results (I've seen up to 4km deviation...)

If it's accuracy you are looking for, then GPS is your only option.

I've read a very informative article about it here.

As for the GPS timeout - 60 seconds should be sufficient, and in most cases even too much. I think 30 seconds is OK and sometimes even less than 5 sec...

if you only need a single location, I'd suggest that in your onLocationChanged method, once you receive an update you'll unregister the listener and avoid unnecessary usage of the GPS.

查看更多
一个人的天荒地老
3楼-- · 2019-01-01 08:06

Android-ReactiveLocation library is another nice library to handle location in Android.

Small library that wraps Google Play Service API in brilliant RxJava Observables reducing boilerplate to minimum.

https://github.com/mcharmas/Android-ReactiveLocation

查看更多
伤终究还是伤i
4楼-- · 2019-01-01 08:07

This is my solution which works fairly well:

private Location bestLocation = null;
private Looper looper;
private boolean networkEnabled = false, gpsEnabled = false;

private synchronized void setLooper(Looper looper) {
    this.looper = looper;
}

private synchronized void stopLooper() {
    if (looper == null) return;
    looper.quit();
}

@Override
protected void runTask() {
    final LocationManager locationManager = (LocationManager) service
            .getSystemService(Context.LOCATION_SERVICE);
    final SharedPreferences prefs = getPreferences();
    final int maxPollingTime = Integer.parseInt(prefs.getString(
            POLLING_KEY, "0"));
    final int desiredAccuracy = Integer.parseInt(prefs.getString(
            DESIRED_KEY, "0"));
    final int acceptedAccuracy = Integer.parseInt(prefs.getString(
            ACCEPTED_KEY, "0"));
    final int maxAge = Integer.parseInt(prefs.getString(AGE_KEY, "0"));
    final String whichProvider = prefs.getString(PROVIDER_KEY, "any");
    final boolean canUseGps = whichProvider.equals("gps")
            || whichProvider.equals("any");
    final boolean canUseNetwork = whichProvider.equals("network")
            || whichProvider.equals("any");
    if (canUseNetwork)
        networkEnabled = locationManager
                .isProviderEnabled(LocationManager.NETWORK_PROVIDER);
    if (canUseGps)
        gpsEnabled = locationManager
                .isProviderEnabled(LocationManager.GPS_PROVIDER);
    // If any provider is enabled now and we displayed a notification clear it.
    if (gpsEnabled || networkEnabled) removeErrorNotification();
    if (gpsEnabled)
        updateBestLocation(locationManager
                .getLastKnownLocation(LocationManager.GPS_PROVIDER));
    if (networkEnabled)
        updateBestLocation(locationManager
                .getLastKnownLocation(LocationManager.NETWORK_PROVIDER));
    if (desiredAccuracy == 0
            || getLocationQuality(desiredAccuracy, acceptedAccuracy,
                    maxAge, bestLocation) != LocationQuality.GOOD) {
        // Define a listener that responds to location updates
        LocationListener locationListener = new LocationListener() {

            public void onLocationChanged(Location location) {
                updateBestLocation(location);
                if (desiredAccuracy != 0
                        && getLocationQuality(desiredAccuracy,
                                acceptedAccuracy, maxAge, bestLocation)
                                == LocationQuality.GOOD)
                    stopLooper();
            }

            public void onProviderEnabled(String provider) {
                if (isSameProvider(provider,
                        LocationManager.NETWORK_PROVIDER))networkEnabled =true;
                else if (isSameProvider(provider,
                        LocationManager.GPS_PROVIDER)) gpsEnabled = true;
                // The user has enabled a location, remove any error
                // notification
                if (canUseGps && gpsEnabled || canUseNetwork
                        && networkEnabled) removeErrorNotification();
            }

            public void onProviderDisabled(String provider) {
                if (isSameProvider(provider,
                        LocationManager.NETWORK_PROVIDER))networkEnabled=false;
                else if (isSameProvider(provider,
                        LocationManager.GPS_PROVIDER)) gpsEnabled = false;
                if (!gpsEnabled && !networkEnabled) {
                    showErrorNotification();
                    stopLooper();
                }
            }

            public void onStatusChanged(String provider, int status,
                    Bundle extras) {
                Log.i(LOG_TAG, "Provider " + provider + " statusChanged");
                if (isSameProvider(provider,
                        LocationManager.NETWORK_PROVIDER)) networkEnabled = 
                        status == LocationProvider.AVAILABLE
                        || status == LocationProvider.TEMPORARILY_UNAVAILABLE;
                else if (isSameProvider(provider,
                        LocationManager.GPS_PROVIDER))
                    gpsEnabled = status == LocationProvider.AVAILABLE
                      || status == LocationProvider.TEMPORARILY_UNAVAILABLE;
                // None of them are available, stop listening
                if (!networkEnabled && !gpsEnabled) {
                    showErrorNotification();
                    stopLooper();
                }
                // The user has enabled a location, remove any error
                // notification
                else if (canUseGps && gpsEnabled || canUseNetwork
                        && networkEnabled) removeErrorNotification();
            }
        };
        if (networkEnabled || gpsEnabled) {
            Looper.prepare();
            setLooper(Looper.myLooper());
            // Register the listener with the Location Manager to receive
            // location updates
            if (canUseGps)
                locationManager.requestLocationUpdates(
                        LocationManager.GPS_PROVIDER, 1000, 1,
                        locationListener, Looper.myLooper());
            if (canUseNetwork)
                locationManager.requestLocationUpdates(
                        LocationManager.NETWORK_PROVIDER, 1000, 1,
                        locationListener, Looper.myLooper());
            Timer t = new Timer();
            t.schedule(new TimerTask() {

                @Override
                public void run() {
                    stopLooper();
                }
            }, maxPollingTime * 1000);
            Looper.loop();
            t.cancel();
            setLooper(null);
            locationManager.removeUpdates(locationListener);
        } else // No provider is enabled, show a notification
        showErrorNotification();
    }
    if (getLocationQuality(desiredAccuracy, acceptedAccuracy, maxAge,
            bestLocation) != LocationQuality.BAD) {
        sendUpdate(new Event(EVENT_TYPE, locationToString(desiredAccuracy,
                acceptedAccuracy, maxAge, bestLocation)));
    } else Log.w(LOG_TAG, "LocationCollector failed to get a location");
}

private synchronized void showErrorNotification() {
    if (notifId != 0) return;
    ServiceHandler handler = service.getHandler();
    NotificationInfo ni = NotificationInfo.createSingleNotification(
            R.string.locationcollector_notif_ticker,
            R.string.locationcollector_notif_title,
            R.string.locationcollector_notif_text,
            android.R.drawable.stat_notify_error);
    Intent intent = new Intent(
            android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS);
    ni.pendingIntent = PendingIntent.getActivity(service, 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT);
    Message msg = handler.obtainMessage(ServiceHandler.SHOW_NOTIFICATION);
    msg.obj = ni;
    handler.sendMessage(msg);
    notifId = ni.id;
}

private void removeErrorNotification() {
    if (notifId == 0) return;
    ServiceHandler handler = service.getHandler();
    if (handler != null) {
        Message msg = handler.obtainMessage(
                ServiceHandler.CLEAR_NOTIFICATION, notifId, 0);
        handler.sendMessage(msg);
        notifId = 0;
    }
}

@Override
public void interrupt() {
    stopLooper();
    super.interrupt();
}

private String locationToString(int desiredAccuracy, int acceptedAccuracy,
        int maxAge, Location location) {
    StringBuilder sb = new StringBuilder();
    sb.append(String.format(
            "qual=%s time=%d prov=%s acc=%.1f lat=%f long=%f",
            getLocationQuality(desiredAccuracy, acceptedAccuracy, maxAge,
                    location), location.getTime() / 1000, // Millis to
                                                            // seconds
            location.getProvider(), location.getAccuracy(), location
                    .getLatitude(), location.getLongitude()));
    if (location.hasAltitude())
        sb.append(String.format(" alt=%.1f", location.getAltitude()));
    if (location.hasBearing())
        sb.append(String.format(" bearing=%.2f", location.getBearing()));
    return sb.toString();
}

private enum LocationQuality {
    BAD, ACCEPTED, GOOD;

    public String toString() {
        if (this == GOOD) return "Good";
        else if (this == ACCEPTED) return "Accepted";
        else return "Bad";
    }
}

private LocationQuality getLocationQuality(int desiredAccuracy,
        int acceptedAccuracy, int maxAge, Location location) {
    if (location == null) return LocationQuality.BAD;
    if (!location.hasAccuracy()) return LocationQuality.BAD;
    long currentTime = System.currentTimeMillis();
    if (currentTime - location.getTime() < maxAge * 1000
            && location.getAccuracy() <= desiredAccuracy)
        return LocationQuality.GOOD;
    if (acceptedAccuracy == -1
            || location.getAccuracy() <= acceptedAccuracy)
        return LocationQuality.ACCEPTED;
    return LocationQuality.BAD;
}

private synchronized void updateBestLocation(Location location) {
    bestLocation = getBestLocation(location, bestLocation);
}

protected Location getBestLocation(Location location,
        Location currentBestLocation) {
    if (currentBestLocation == null) {
        // A new location is always better than no location
        return location;
    }
    if (location == null) return currentBestLocation;
    // Check whether the new location fix is newer or older
    long timeDelta = location.getTime() - currentBestLocation.getTime();
    boolean isSignificantlyNewer = timeDelta > TWO_MINUTES;
    boolean isSignificantlyOlder = timeDelta < -TWO_MINUTES;
    boolean isNewer = timeDelta > 0;
    // If it's been more than two minutes since the current location, use
    // the new location
    // because the user has likely moved
    if (isSignificantlyNewer) {
        return location;
        // If the new location is more than two minutes older, it must be
        // worse
    } else if (isSignificantlyOlder) {
        return currentBestLocation;
    }
    // Check whether the new location fix is more or less accurate
    int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation
            .getAccuracy());
    boolean isLessAccurate = accuracyDelta > 0;
    boolean isMoreAccurate = accuracyDelta < 0;
    boolean isSignificantlyLessAccurate = accuracyDelta > 200;
    // Check if the old and new location are from the same provider
    boolean isFromSameProvider = isSameProvider(location.getProvider(),
            currentBestLocation.getProvider());
    // Determine location quality using a combination of timeliness and
    // accuracy
    if (isMoreAccurate) {
        return location;
    } else if (isNewer && !isLessAccurate) {
        return location;
    } else if (isNewer && !isSignificantlyLessAccurate
            && isFromSameProvider) {
        return location;
    }
    return bestLocation;
}

/** Checks whether two providers are the same */
private boolean isSameProvider(String provider1, String provider2) {
    if (provider1 == null) return provider2 == null;
    return provider1.equals(provider2);
}
查看更多
残风、尘缘若梦
5楼-- · 2019-01-01 08:11

Currently i am using since this is trustable for getting location and calculating distance for my application...... i am using this for my taxi application.

use the fusion API that google developer have developed with fusion of GPS Sensor,Magnetometer,Accelerometer also using Wifi or cell location to calculate or estimate the location. It is also able to give location updates also inside the building accurately. for detail get to link https://developers.google.com/android/reference/com/google/android/gms/location/FusedLocationProviderApi

import android.app.Activity;
import android.location.Location;
import android.os.Bundle;
import android.support.v7.app.ActionBarActivity;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks;
import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;

import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;


public class MainActivity extends Activity implements LocationListener,
        GoogleApiClient.ConnectionCallbacks,
        GoogleApiClient.OnConnectionFailedListener {

    private static final long ONE_MIN = 500;
    private static final long TWO_MIN = 500;
    private static final long FIVE_MIN = 500;
    private static final long POLLING_FREQ = 1000 * 20;
    private static final long FASTEST_UPDATE_FREQ = 1000 * 5;
    private static final float MIN_ACCURACY = 1.0f;
    private static final float MIN_LAST_READ_ACCURACY = 1;

    private LocationRequest mLocationRequest;
    private Location mBestReading;
TextView tv;
    private GoogleApiClient mGoogleApiClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (!servicesAvailable()) {
            finish();
        }

        setContentView(R.layout.activity_main);
tv= (TextView) findViewById(R.id.tv1);
        mLocationRequest = LocationRequest.create();
        mLocationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
        mLocationRequest.setInterval(POLLING_FREQ);
        mLocationRequest.setFastestInterval(FASTEST_UPDATE_FREQ);

        mGoogleApiClient = new GoogleApiClient.Builder(this)
                .addApi(LocationServices.API)
                .addConnectionCallbacks(this)
                .addOnConnectionFailedListener(this)
                .build();


        if (mGoogleApiClient != null) {
            mGoogleApiClient.connect();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();

        if (mGoogleApiClient != null) {
            mGoogleApiClient.connect();
        }
    }

    @Override
    protected void onPause() {d
        super.onPause();

        if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
            mGoogleApiClient.disconnect();
        }
    }


        tv.setText(location + "");
        // Determine whether new location is better than current best
        // estimate
        if (null == mBestReading || location.getAccuracy() < mBestReading.getAccuracy()) {
            mBestReading = location;


            if (mBestReading.getAccuracy() < MIN_ACCURACY) {
                LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleApiClient, this);
            }
        }
    }

    @Override
    public void onConnected(Bundle dataBundle) {
        // Get first reading. Get additional location updates if necessary
        if (servicesAvailable()) {

            // Get best last location measurement meeting criteria
            mBestReading = bestLastKnownLocation(MIN_LAST_READ_ACCURACY, FIVE_MIN);

            if (null == mBestReading
                    || mBestReading.getAccuracy() > MIN_LAST_READ_ACCURACY
                    || mBestReading.getTime() < System.currentTimeMillis() - TWO_MIN) {

                LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, mLocationRequest, this);

               //Schedule a runnable to unregister location listeners

                    @Override
                    public void run() {
                        LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleApiClient, MainActivity.this);

                    }

                }, ONE_MIN, TimeUnit.MILLISECONDS);

            }

        }
    }

    @Override
    public void onConnectionSuspended(int i) {

    }


    private Location bestLastKnownLocation(float minAccuracy, long minTime) {
        Location bestResult = null;
        float bestAccuracy = Float.MAX_VALUE;
        long bestTime = Long.MIN_VALUE;

        // Get the best most recent location currently available
        Location mCurrentLocation = LocationServices.FusedLocationApi.getLastLocation(mGoogleApiClient);
        //tv.setText(mCurrentLocation+"");
        if (mCurrentLocation != null) {
            float accuracy = mCurrentLocation.getAccuracy();
            long time = mCurrentLocation.getTime();

            if (accuracy < bestAccuracy) {
                bestResult = mCurrentLocation;
                bestAccuracy = accuracy;
                bestTime = time;
            }
        }

        // Return best reading or null
        if (bestAccuracy > minAccuracy || bestTime < minTime) {
            return null;
        }
        else {
            return bestResult;
        }
    }

    @Override
    public void onConnectionFailed(ConnectionResult connectionResult) {

    }

    private boolean servicesAvailable() {
        int resultCode = GooglePlayServicesUtil.isGooglePlayServicesAvailable(this);

        if (ConnectionResult.SUCCESS == resultCode) {
            return true;
        }
        else {
            GooglePlayServicesUtil.getErrorDialog(resultCode, this, 0).show();
            return false;
        }
    }
}
查看更多
初与友歌
6楼-- · 2019-01-01 08:12

I scoured the internet for an updated (past year) answer using the latest location pulling methods suggested by google (to use FusedLocationProviderClient). I finally landed on this:

https://github.com/googlesamples/android-play-location/tree/master/LocationUpdates

I created a new project and copied in most of this code. Boom. It works. And I think without any deprecated lines.

Also, the simulator doesn't seem to get a GPS location, that I know of. It did get as far as reporting this in the log: "All location settings are satisfied."

And finally, in case you wanted to know (I did), you DO NOT need a google maps api key from the google developer console, if all you want is the GPS location.

Also useful is their tutorial. But I wanted a full one page tutorial/code example, and that. Their tutorial stacks but is confusing when you're new to this because you don't know what pieces you need from earlier pages.

https://developer.android.com/training/location/index.html

And finally, remember things like this:

I not only had to modify the mainActivity.Java. I also had to modify Strings.xml, androidmanifest.xml, AND the correct build.gradle. And also your activity_Main.xml (but that part was easy for me).

I needed to add dependencies like this one: implementation 'com.google.android.gms:play-services-location:11.8.0', and update the settings of my android studio SDK to include google play services. (file settings appearance system settings android SDK SDK Tools check google play services).

update: the android simulator did seem to get a location and location change events (when I changed the value in the settings of the sim). But my best and first results were on an actual device. So it's probably easiest to test on actual devices.

查看更多
永恒的永恒
7楼-- · 2019-01-01 08:13

Skyhook (http://www.skyhookwireless.com/) has a location provider that is much faster than the standard one Google provides. It might be what you're looking for. I'm not affiliated with them.

查看更多
登录 后发表回答