iOS Tracking CLCircularRegion - Heisenbug

389 views Asked by At

One of my iOS applications seems to have the symptoms of a classic Heisenbug. The application tracks a user's home location so certain events happen when the user enters and exits their home location.

While I'm testing the application, it works great. I walk in and out of a CLCircularRegion and it works every which way I try it. It works with the application in the background. It works with the application closed. It works with the application in the foreground. It works with green eggs and ham.

Unfortunately, users are reporting issues where it will be delayed by 15 minutes or so. The users will enter their homes, but the event will not occur until later. In some cases, the event does not occur at all. The pattern seems to be that when the user first starts using the application, it works great. After a day or so, the application doesn't seem to work as well. The events are delayed.

I'll be the first to admit that I'm no expert on the inner workings of CLLocationManager and CLCircularRegion. I believe I have everything set up properly though and I'm having a really hard time trying to figure out how I can debug something like this.

At any rate, I'll show some of my code here. Keep in mind this is developed with Xamarin so it's in C#.

AppDelegate.cs

public static AppDelegate self;
private CLLocationManager locationManager;
private CLCircularRegion[] locationFences;



private void initializeLocationManager()
{
    this.locationManager = new CLLocationManager();

    // iOS 8 additional permissions requirements
    if (UIDevice.CurrentDevice.CheckSystemVersion(8, 0))
    {
        locationManager.RequestAlwaysAuthorization();
    }

    locationManager.AuthorizationChanged += (sender, e) =>
    {
        var status = e.Status;

        // Location services was turned off or turned off for this specific application.
        if (status == CLAuthorizationStatus.Denied)
        {
            stopLocationUpdates();
        }
        else if (status == CLAuthorizationStatus.AuthorizedAlways &&
            iOSMethods.getKeyChainBool(OptionsViewController.GENERIC, OptionsViewController.SERVICE_GEOLOCATION_ENABLED))
        {
            startLocationUpdates();
        }
    };

    if (CLLocationManager.IsMonitoringAvailable(typeof(CLCircularRegion)))
    {
        locationManager.RegionEntered += (sender, e) =>
        {
            setRegionStatus(e, "Inside");
        };

        locationManager.RegionLeft += (sender, e) =>
        {
            setRegionStatus(e, "Outside");
        };

        locationManager.DidDetermineState += (sender, e) =>
        {
            setRegionStatus(e);
        };
    }
    else
    {
        // cant do it with this device
    }

    init();
}

public void init()
{
    var data = SQL.query<SQLTables.RoomLocationData>("SELECT * FROM RoomLocationData").ToArray();
    int dLen = data.Length;
    if (dLen > 0)
    {
        locationFences = new CLCircularRegion[dLen];
        for (int x = 0; x < dLen; x++)
        {
            var d = data[x];
            CLCircularRegion locationFence = new CLCircularRegion(new CLLocationCoordinate2D(d.Latitude, d.Longitude), d.Radius, d.SomeID.ToString() + ":" + d.AnotherID.ToString());
            locationFence.NotifyOnEntry = true;
            locationFence.NotifyOnExit = true;
            locationFences[x] = locationFence;
        }
    }
}

private void setRegionStatus(CLRegionEventArgs e, string status, bool calledFromDidDetermineState = false)
{
    string identifier = e.Region.Identifier;

    string lastStatus = iOSMethods.getKeyChainItem(OptionsViewController.GENERIC, OptionsViewController.SERVICE_LAST_GEO_STATUS);
    if (lastStatus == status + ":" + identifier)
    {
        return;
    }
    iOSMethods.setKeychainItem(OptionsViewController.GENERIC, OptionsViewController.SERVICE_LAST_GEO_STATUS, status + ":" + identifier);

    string[] split = identifier.Split(new string[] { ":" }, StringSplitOptions.RemoveEmptyEntries);
    if (split.Length == 2)
    {
        try
        {
            int someID = Convert.ToInt32(split[0]);
            int anotherID = Convert.ToInt32(split[1]);

            // Notifies our API of a change.
            updateGeofenceStatus(someID, anotherID, status);

            if (iOSMethods.getKeyChainBool(OptionsViewController.GENERIC, OptionsViewController.SERVICE_GEOLOCATION_NOTIFICATIONS) &&
                (status == "Inside" || status == "Outside" || status == "Unknown"))
            {
                var rm = SQL.query<SQLTables.KeyRoomPropertyData>("SELECT * FROM KeyRoomPropertyData WHERE SomeID ID = ? AND AnotherID = ?",
                    new object[] { someID, anotherID }).ToArray();
                if (rm.Length > 0)
                {
                    if (status == "Unknown")
                    {
                        return;
                    }
                    var rmD = rm[0];
                    UILocalNotification notification = new UILocalNotification();
                    notification.AlertAction = "Geolocation Event";
                    notification.AlertBody = status == "Inside" ? "Entered " + rmD.SomeName + ": " + rmD.AnotherName :
                        status == "Outside" ? "Exited " + rmD.SomeName + ": " + rmD.AnotherName :
                        "Geolocation update failed. If you would like to continue to use Geolocation, please make sure location services are enabled and are allowed for this application.";
                    notification.SoundName = UILocalNotification.DefaultSoundName;
                    notification.FireDate = NSDate.Now;
                    UIApplication.SharedApplication.ScheduleLocalNotification(notification);
                }
            }
        }
        catch (Exception er)
        {
            // conversion failed. we don't have ints for some reason.
        }
    }
}

private void setRegionStatus(CLRegionStateDeterminedEventArgs e)
{
    string state = "";
    if (e.State == CLRegionState.Inside)
    {
        state = "Inside";
    }
    else if (e.State == CLRegionState.Outside)
    {
        state = "Outside";
    }
    else
    {
        state = "Unknown";
    }
    CLRegionEventArgs ee = new CLRegionEventArgs(e.Region);
    setRegionStatus(ee, state, true);
}

public void startLocationUpdates()
{
    if (CLLocationManager.LocationServicesEnabled)
    {
        init();
        if (locationFences != null)
        {
            foreach (CLCircularRegion location in locationFences)
            {
                locationManager.StartMonitoring(location);
                Timer t = new Timer(new TimerCallback(delegate(object o) { locationManager.RequestState(location); }), null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(-1));
            }
        }
    }
}

public void stopLocationUpdates(bool isRestarting = false)
{
    if (locationFences != null)
    {
        foreach (CLCircularRegion location in locationFences)
        {
            locationManager.StopMonitoring(location);
        }
    }
    if (!isRestarting)
    {
        var rooms = SQL.query<SQLTables.KeyRoomPropertyData>("SELECT * FROM KeyRoomPropertyData").ToArray();
        foreach (SQLTables.KeyRoomPropertyData room in rooms)
        {
            // notifies our API of a change
            updateGeofenceStatus(room.SomeID, room.AnotherID, "Unknown");
        }
    }
}

I know it's a lot of code for anyone to sift through, but I really have no good theory at this point as to what is causing this bug or if it is even possible to fix with the limitations of iOS.

A few theories that I have are if the CLLocationManager.PausesLocationUpdatesAutomatically property may have something to do with it, or some other property of CLLocationManager such as ActivityType, DesiredAccuracy, or DistanceFilter. I've left all of these at their defaults which I would assume would be fine, but I'm not really sure.

Another theory is that there is an uncaught exception being thrown some time after the "service" has been running in the background for some time. If that is the case, is there anything iOS does that would give me a stack trace or something? In all of my tests, I never ran across any exceptions being thrown from this code so I kind of doubt that's the issue. At this point though, I'm willing to entertain any ideas or suggestions.

Also, please keep in mind that in order for this application to work the way it was intended, the location update events MUST occur as soon as the user enters or exist the CLCircularRegion (within a minute or so at least). Obviously I have to leave it to the user to keep their location services enabled and allow the app to have the appropriate permissions.

2

There are 2 answers

2
Alex Pavlov On

You are most likely right on target with your diagnosis - it is classic observer effect.

When you test the app, when users play with a new app, the iphone is being actively used. It is not given a chance to fall asleep. What happens on a next day, when users return home - their phones most likely are not in use for extended time right before reaching home location: normally we do not use phones during "last mile" walk after leaving public transportation, or while driving back home. iOS notices this extended inactivity period and adjusts its own behavior to optimize battery life.

The easiest way to observe this is to put together a simple breadcrumbs app - set geofence at your location and keep doing that every time you get exit event. Depending on the way you use (or not use) your phone results will be very different while walking the same route.

And when you get home, the phone is usually the last thing you reach for as well.

You may want to ask users to give more details on how exactly they used phones last 15 minutes before and after entering home, what other apps they use, if they drive do they keep turn by turn navigation app running etc. You will spot the pattern.

re. Also, please keep in mind that in order for this application to work the way it was intended, the location update events MUST occur as soon as the user enters or exist the CLCircularRegion (within a minute or so at least).

You can't do this with geofencing only, especially taking into account different arrival/departure patterns - walking vs driving, "descend" paths (e.g. arrivals with U-turns). You have to anticipate both delays longer than 1 minute and "premature" triggering. I am afraid there is no workaround.

1
hungri-yeti On

Some things to check:

  1. What are some typical values for radius? You may want to consider reducing that.

  2. iOS Location Services will provide a quicker response if the device has WiFi enabled even if the user is not connected to a network. Check if the problem users have wifi disabled and if you haven't done so already maybe test your device w/o wifi.

  3. Is there a delay in the notification? That is, does the region event occur correctly but for some reason there is a delay in the notification?

  4. How many RoomLocationData entries are there? iOS limits each app to 20 regions max.

  5. Presuming the users are driving to/from their house, you may want to try the following settings (code is Swift):

    locationManager.distanceFilter = kCLDistanceFilterNone
    locationManager.desiredAccuracy = kCLLocationAccuracyBest // or kCLLocationAccuracyBestForNavigation
    locationManager.pausesLocationUpdatesAutomatically = true // try false if nothing else works
    locationManager.allowsBackgroundLocationUpdates = true
    locationManager.activityType = CLActivityType.AutomotiveNavigation