68

For quite a while I'd been looking into a way in my iPhone app to poll every X minutes to check the data counters. After much reading of the Background Execution documentation and a few trial apps I'd dismissed this as impossible without abusing the background APIs.

Last week I found this application which does exactly that. http://itunes.apple.com/us/app/dataman-real-time-data-usage/id393282873?mt=8

It runs in the background and keeps track of the count of Cellular/WiFi data you've used. I suspect that the developer is registering his app as tracking location changes but the location services icon isn't visible while the app is running, which I thought was a requirement.

Does anyone have any clues as to how this can be accomplished?

NeilInglis
  • 3,431
  • 4
  • 30
  • 31
  • Another application that does this: http://itunes.apple.com/gb/app/georing-tag-your-calls-music/id411898845?mt=8 I'm totally stumped. It doesn't seem to use Location services as the icon isn't visible and it can't be chaining together background events as the 10 minute timeout is absolute and you can't start another. It's really annoying me now, from a technical perspective, that I don't know how to do it. – NeilInglis Jan 24 '11 at 12:48
  • Have you had you problem resolved? I wonder if Jack's answer solve your problem (without VOIP feature) – Tuyen Nguyen Mar 03 '12 at 10:41
  • Relevant link to Apple's [Background Execution](https://developer.apple.com/library/ios/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/BackgroundExecution/BackgroundExecution.html) docs. – sherb Dec 23 '14 at 18:13

7 Answers7

92

I have seen this behavior, too. After trying a lot I discovered two things, which could help. But I am still uncertain how this may influence the reviewing process.

If you use one of the backgrounding features, the app will be launched by iOS in background again once it was quit (by the system). This we will abuse later.

In my case I used VoIP backgrounding enabled in my plist. All the code here is done in your AppDelegate:

// if the iOS device allows background execution,
// this Handler will be called
- (void)backgroundHandler {

    NSLog(@"### -->VOIP backgrounding callback");
    // try to do sth. According to Apple we have ONLY 30 seconds to perform this Task!
    // Else the Application will be terminated!
    UIApplication* app = [UIApplication sharedApplication];
    NSArray*    oldNotifications = [app scheduledLocalNotifications];

     // Clear out the old notification before scheduling a new one.
    if ([oldNotifications count] > 0) [app cancelAllLocalNotifications];

    // Create a new notification
    UILocalNotification* alarm = [[[UILocalNotification alloc] init] autorelease];
    if (alarm)
    {
        alarm.fireDate = [NSDate date];
        alarm.timeZone = [NSTimeZone defaultTimeZone];
        alarm.repeatInterval = 0;
        alarm.soundName = @"alarmsound.caf";
        alarm.alertBody = @"Don't Panic! This is just a Push-Notification Test.";

        [app scheduleLocalNotification:alarm];
    }
}

and the registration is done in

- (void)applicationDidEnterBackground:(UIApplication *)application {

    // This is where you can do your X Minutes, if >= 10Minutes is okay.
    BOOL backgroundAccepted = [[UIApplication sharedApplication] setKeepAliveTimeout:600 handler:^{ [self backgroundHandler]; }];
    if (backgroundAccepted)
    {
        NSLog(@"VOIP backgrounding accepted");
    }
}

Now the magic happens: I don't even use VoIP-Sockets. But this 10 Minutes callback provides a nice side effect: After 10 Minutes (sometimes earlier) I discovered that my timers and previous running treads are being executed for a short while. You can see this, if you place some NSLog(..) into your code. This means, that this short "wakeup" executes the code for a while. According to Apple we have 30 seconds execution time left. I assume, that background code like threads are being executed for nearly 30 seconds. This is useful code, if you must "sometimes" check something.

The doc says that all background tasks (VoIP, audio, location updates) will be automatically restarted in background if the app was terminated. VoIP apps will be started in background automatically after bootup!

With abusing this behavior, you can make your app be looking like running "forever". Register for one background process (i.e. VoIP). This will cause your app to be restarted after termination.

Now write some "Task has to be finished" code. According to Apple you have some time (5 seconds?) left to finish tasks. I discovered, that this must be CPU time. So that means: if you do nothing, your app is still being executed! Apple suggest to call an expirationhandler, if you are finished with your work. In the code below you can see, that i have a comment at the expirationHandler. This will cause your app running as long as the system allows your app to be running. All timers and threads stay running until iOS terminates your app.

- (void)applicationDidEnterBackground:(UIApplication *)application {

    UIApplication*    app = [UIApplication sharedApplication];

    bgTask = [app beginBackgroundTaskWithExpirationHandler:^{
        [app endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];


    // Start the long-running task and return immediately.
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    // you can do sth. here, or simply do nothing!
    // All your background treads and timers are still being executed
    while (background) 
       [self doSomething];
       // This is where you can do your "X minutes" in seconds (here 10)
       sleep(10);
    }

    // And never call the expirationHandler, so your App runs
    // until the system terminates our process
    //[app endBackgroundTask:bgTask];
    //bgTask = UIBackgroundTaskInvalid;

    }); 
}

Be very spare with CPU-Time here, and your app runs longer! But one thing is for sure: your app will be terminated after a while. But because you registered your app as VoIP or one of the others, the system restarts the app in background, which will restart your background process ;-) With this PingPong I can do a lot of backgrounding. but remember be very spare with CPU time. And save all data, to restore your views - your app will be terminated some time later. To make it appear still running, you must jump back into your last "state" after wakeup.

I don't know if this is the approach of the apps you mentioned before, but it works for me.

Hope I could help

Update:

After measuring the time of the BG task, there was a surprise. The BG Task is limited to 600 seconds. This is the exact minimum time of the VoIP minimumtime (setKeepAliveTimeout:600).

So THIS code leads into "infinite" execution in background:

Header:

UIBackgroundTaskIdentifier bgTask; 

Code:

// if the iOS device allows background execution,
// this Handler will be called
- (void)backgroundHandler {

    NSLog(@"### -->VOIP backgrounding callback");

    UIApplication*    app = [UIApplication sharedApplication];

    bgTask = [app beginBackgroundTaskWithExpirationHandler:^{
        [app endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];

    // Start the long-running task 
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    while (1) {
        NSLog(@"BGTime left: %f", [UIApplication sharedApplication].backgroundTimeRemaining);
           [self doSomething];
        sleep(1);
    }   
});     

- (void)applicationDidEnterBackground:(UIApplication *)application {

    BOOL backgroundAccepted = [[UIApplication sharedApplication] setKeepAliveTimeout:600 handler:^{ [self backgroundHandler]; }];
    if (backgroundAccepted)
    {
        NSLog(@"VOIP backgrounding accepted");
    }

    UIApplication*    app = [UIApplication sharedApplication];

    bgTask = [app beginBackgroundTaskWithExpirationHandler:^{
        [app endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];


    // Start the long-running task
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

        while (1) {
            NSLog(@"BGTime left: %f", [UIApplication sharedApplication].backgroundTimeRemaining);
           [self doSomething];
           sleep(1);
        }    
    }); 
}

After your app has timed out, the VoIP expirationHandler will be called, where you simply restart a long running task. This task will be terminated after 600 seconds. But there will be again a call to the expiration handler, which starts another long running task, etc. Now you only have to check weather the App is getting back to foreground. Then close the bgTask, and you're done. Maybe one can do sth. like this inside the expirationHandler from the long running task. Just try it out. Use your Console, to see what happens... Have Fun!

Update 2:

Sometimes simplifying things helps. My new approach is this one:

- (void)applicationDidEnterBackground:(UIApplication *)application {

    UIApplication*    app = [UIApplication sharedApplication];

    // it's better to move "dispatch_block_t expirationHandler"
    // into your headerfile and initialize the code somewhere else
    // i.e. 
    // - (void)applicationDidFinishLaunching:(UIApplication *)application {
//
// expirationHandler = ^{ ... } }
    // because your app may crash if you initialize expirationHandler twice.
    dispatch_block_t expirationHandler;
    expirationHandler = ^{

        [app endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;


        bgTask = [app beginBackgroundTaskWithExpirationHandler:expirationHandler];
    };

    bgTask = [app beginBackgroundTaskWithExpirationHandler:expirationHandler];


    // Start the long-running task and return immediately.
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

        // inform others to stop tasks, if you like
        [[NSNotificationCenter defaultCenter] postNotificationName:@"MyApplicationEntersBackground" object:self];

        // do your background work here     
    }); 
}

This is working without the VoIP hack. According to the documentation, the expiration handler (in this case my 'expirationHandler' block) will be executed if execution time is over. By defining the block into a block variable, one can recursively start the long running task again within the expiration handler. This leads into endless execution, too.

Be aware to terminate the task, if your application enters foreground again. And terminate the task if you don't need it anymore.

For my own experience I measured something. Using the location callbacks with having the GPS radio on is sucking my battery down very quickly. Using the approach which I posted in Update 2 is taking nearly no energy. According to the "userexperience" this is a better approach. Maybe other Apps work like this, hiding its behavior behind GPS functionality ...

JackPearse
  • 2,922
  • 23
  • 31
  • Great information in this post, thanks. I'm not sure that this is the method that DataMan is using but it seems like it'd work. I wouldn't risk submitting it to the app store though, smells a bit like abuse. – NeilInglis Jan 27 '11 at 20:23
  • 1
    Yes, this seems to be "abuse". But I think the app store has changed their "hardness". And if you use the framework right (i.e. your bgtask never expires) you are aware of the framework. That means: Technically this is no violation, because you always use the framework right. I am still working on this problem. Perhaps I will find a better solution, than I will post it here. Greetings. – JackPearse Jan 28 '11 at 09:58
  • JackPearse, are you confirm "infinite" execution in background is working?? After my testing, it can hold 10 min then after 10 sec, the application is kill by ios. –  Feb 10 '11 at 03:33
  • 2
    Hi, this depends on your coding. Take a look at the last example. After 10 seconds the framework will terminate the task, if you don't terminate your bgtask. Your bghandler (the block, which is provided) will terminate the bgtask. Inside the block a new bgtask is being started. In my app its working. I discovered, that you should make the block variable "global" inside your headerfile and initialize it at startup. The app is being terminated, if you use initialize the block-variable twice. – JackPearse Feb 10 '11 at 08:44
  • 1
    Add on: Don't use the VoIP callback together with the backgroundhandler approach. Starting a bgtask twice may lead into termination. That means: Use ONLY example in "Update 2" section. It should work. – JackPearse Feb 10 '11 at 08:52
  • Actually it turns out this only works for me when I'm running in debug mode with gdb attached. Otherwise the watchdog kills my app. Have you verified that it works when not attached with gdb? – NeilInglis Feb 21 '11 at 16:07
  • "Be aware to terminate the task, if your application enters foreground again. And terminate the task if you don't need it anymore" but how? – Mejdi Lassidi May 17 '11 at 09:45
  • In apple documentation they said when talking about apps that use to do background work based on track of user 's location:"For applications that require more precise location data at regular intervals, such as navigation applications, you need to declare the application as a continuous background application. This option is available for applications that truly need it, but it is the least desirable option because it increases power usage considerably." My question how can i declare the application as a continuous background application? – Mejdi Lassidi May 17 '11 at 10:07
  • @NeilInglis sorry from bringing this back again, but isn't this big news or am I missing something? I'm running some test now, just logging the date and the battery level every 10 seconds, and it seems to work fine, even when not running in debug mode. Do you have any more comments about this technique? – phi Aug 18 '11 at 13:42
  • hi jack. im using this code but had a quick question. have you submitted anything with this and can verify it wont be rejected? also do i stop this task by using [app endBackgroundTask:bgTask]; ? probably in the applicationwillenterforeground method? – owen gerig Nov 29 '11 at 15:34
  • One app of ours has been rejected. But not because of this backgrounding, but because we have no VOIP-App, so we were not allowed to use this. Maybe if your App does sth. with VOIP it will be allowed. – JackPearse Dec 02 '11 at 13:36
  • @owengering Keeping it running in the background is going fine (still without the VOIP feature). But you have no guarantee, that your app will not be terminated. I.e. on some iPhones the bg task is being terminated after 10 Minutes or if a phone call is coming in. On my iPod touch (4th generation) its running and running. I suppose that the decision for termination is done by the OS depending on the circumstances of the OS. So if you can live with this, it should be fine. – JackPearse Dec 02 '11 at 13:41
  • and yes: if your timeout is coming, you stop it with app endBackgrounding task:bgTask. You can play around with this. Put a NSLog into the block and maybe a sleep(1). Then you should see a log on the console each second. If your log is not displayed anymore, the task is terminated. – JackPearse Dec 02 '11 at 13:45
  • @JackPearse: Can you share a sample code project which works for you? I tried the code snippets you've provided but it doesn't work for me. Providing a sample code project will be helpful (preferably for update 2). – Sahil Khanna Dec 23 '11 at 08:04
  • this post is like crack i keep coming back in hopes of it solving my problem. however i cannot get this to work for more then 10mins. and the documentation on apple of how to get a voip client to receiving incoming call packets as well as a keepalive, is sub par at best. – owen gerig Jan 06 '12 at 15:44
  • Hi JackPearse, I follow your update 2 (without VOIP feature), the app crashes at line "bgTask = UIBackgroundTaskInvalid;" after running in background for the first 10 minutes, do you know what I might be mising? – Tuyen Nguyen Mar 03 '12 at 10:34
  • Hi,I tried update 2 but not working for me. task stopped after 10min. Anybody got working? I am trying with iOS5.0 – Iducool Jul 19 '12 at 13:19
  • @JackPearse update 2 is not working for me. update 1 is working .Any help? – NSCry Nov 06 '12 at 06:43
  • 1
    This works in principle, but not as coded in any of the examples in this post. I suggest taking the code in the `expirationHandler` block and moving it to its own method in your class. Then whenever you call `beginBackgroundTaskWithExpirationHandler:` you can simply pass `^{[self expirationHandler];}` as the parameter. This avoids a number of issues that seem to be caused by having the block refer back to itself when it executes. – aroth Nov 21 '12 at 04:08
  • 1
    You need to add __block to the block variable expirationHandler in order for it to reference itself. This way it can see updates to the variable. As is, it just captures the value of the variable at block creation time (nil). – Christopher Rogers Mar 22 '13 at 01:52
  • Christopher Rogers you made my day. Thank you! – Ahmed Ahmedov Apr 18 '13 at 22:42
  • I'm still confused as to what the final code is for the properly working Update 2 solution. Any added infer would be great please. – mejim707 May 09 '13 at 23:54
  • who ever run IOS6 for this snippet ? did apple change any policy on this? because it not work for me on IOS6? – zolibra Oct 12 '13 at 06:36
  • I also tried Update 1 : after the first 600s background remainning , the app only remain 60s then quit. so i think app has change the policy since IOS 6 – zolibra Oct 12 '13 at 07:36
  • it works only for 3 min. any solution found for this limitation? – Ayaz Alavi Dec 29 '13 at 18:45
  • 4
    This solution works intermittently, and is dependent on how each iOS version implements suspension of background apps. For example, I believe it worked in iOS 6.0, but stopped working in a 6.1 version. Similarly, I got this to work in 7.0, and then it broke again in 7.1, as Apple got more aggressive about managing battery drain again. So, in short, it's not reliable. – Nate May 15 '14 at 05:28
17

What Works & What Doesn't

It's not entirely clear which of these answers work & I've wasted a lot of time trying them all. So here's my experience with each strategy:

  1. VOIP hack - works, but will get you rejected if you're not a VOIP app
  2. Recursive beginBackgroundTask... - does not work. It will quit after 10 minutes. Even if you try the fixes in the comments (at least the comments up to Nov 30, 2012).
  3. Silent Audio - works, but people have been rejected for this
  4. Local/Push Notifications - require user interaction before your app will be woken up
  5. Using Background Location - works. Here are the details:

Basically you use the "location" background mode to keep your app running in the background. It does work, even if the user does not allow location updates. Even if the user presses the home button and launches another app, your app will still be running. It's also a battery drainer & may be a stretch in the approval process if your app has nothing to do with location, but as far as I know it's the only solution that has a good chance of being approved.

Here's how it works:

In your plist set:

  • Application does not run in background: NO
  • Required background modes: location

Then reference the CoreLocation framework (in Build Phases) and add this code somewhere in your app (before it goes into the background):

#import <CoreLocation/CoreLocation.h>

CLLocationManager* locationManager = [[CLLocationManager alloc] init];
[locationManager startUpdatingLocation];

Note: startMonitoringSignificantLocationChanges will not work.

It's also worth mentioning that if your app crashes, then iOS will not bring it back to life. The VOIP hack is the only one that can bring it back.

Community
  • 1
  • 1
bendytree
  • 13,095
  • 11
  • 75
  • 91
  • Why do you say: "Note: startMonitoringSignificantLocationChanges will not work"? That mode is supported when running in the background and won't drain the battery as much. – Lolo Feb 22 '13 at 05:29
  • 2
    In my testing, `startMonitoringSignificantLocationChanges` will run in the background but does not prevent the app from going to sleep after a period of time. – bendytree Feb 22 '13 at 14:20
  • Strange since you also specify that the user doesn't need to allow location updates, meaning that it hasn't anything to do with the frequency of the updates. Maybe it has to do with the necessity for the app to keep some hardware components running? In any case, thanks for the clarification. – Lolo Feb 22 '13 at 15:09
6

There is another technique to stay forever in the background - starting/stopping location manager in your background task, will reset the background timer when didUpdateToLocation: is called.

I don't know why it works, but I think didUpdateToLocation is also called as a task and thereby resets the timer.

Based on testing, I believe this is what DataMan Pro is using.

See this post https://stackoverflow.com/a/6465280 where I got trick from.

Here are some results from our app:

2012-02-06 15:21:01.520 **[1166:4027] BGTime left: 598.614497 
2012-02-06 15:21:02.897 **[1166:4027] BGTime left: 597.237567 
2012-02-06 15:21:04.106 **[1166:4027] BGTime left: 596.028215 
2012-02-06 15:21:05.306 **[1166:4027] BGTime left: 594.828474 
2012-02-06 15:21:06.515 **[1166:4027] BGTime left: 593.619191
2012-02-06 15:21:07.739 **[1166:4027] BGTime left: 592.395392 
2012-02-06 15:21:08.941 **[1166:4027] BGTime left: 591.193865 
2012-02-06 15:21:10.134 **[1166:4027] BGTime left: 590.001071
2012-02-06 15:21:11.339 **[1166:4027] BGTime left: 588.795573
2012-02-06 15:21:11.351 **[1166:707] startUpdatingLocation
2012-02-06 15:21:11.543 **[1166:707] didUpdateToLocation
2012-02-06 15:21:11.623 **[1166:707] stopUpdatingLocation
2012-02-06 15:21:13.050 **[1166:4027] BGTime left: 599.701993
2012-02-06 15:21:14.286 **[1166:4027] BGTime left: 598.465553
Community
  • 1
  • 1
Kashif Shaikh
  • 81
  • 1
  • 3
  • This works nice, but i don't think DataMan is using this as I don't remember ever being asked for allowing Location permissions. – htafoya Mar 10 '12 at 20:21
1

If it's not GPS I think the only other way of doing it is the background music function, i.e., playing 4"33" all the time it's enabled. Both sound like a bit of an abuse of the background processing APIs and so potentially subject to the whims of the review process.

Stephen Darlington
  • 51,577
  • 12
  • 107
  • 152
  • 7
    "playing 4'33" all the time it's enabled" Best thing I've read all day. – BoltClock Jan 24 '11 at 13:08
  • 1
    I had a look in the plist and it only has 'location' enabled for UIBackgroundModes so it's not audio. I wonder if he's found a way to hide the location indicator. – NeilInglis Jan 24 '11 at 13:20
1

I tried the Update 2 but it just doesn't work. When the expiration handler is called, it ends the background task. Then starting a new background task just forces an immediate call to the expiration handler again (the timer is not reset and is still expired). Thus I got 43 starts/stops of background tasks before the app was suspended.

iOSTester
  • 81
  • 1
  • 1
  • Yeah, it's not working for me either UNLESS I'm attached via gdb the the backgroundTimeRemaining seems happy to go negative. Otherwise it's killed by watchdog. I've tried many variations, but can't get any to stick. – NeilInglis Feb 23 '11 at 10:25
  • Hi. Thank you for your comment. Now I've seen, that it makes a difference if you use debug mode or ADHoc release. I will stay working on it. So if there's any clean solution for this, I will post it here immediately. Actually I am waiting for the VOIP Timeout to do "short" backgroundings. But this will happen every 10 Minutes. – JackPearse Mar 02 '11 at 09:28
  • I use VoIP and GPS(I need GPS anyway for my app) and it works(enabling gps does trick with watchdog). I perform my small processing every second. Of course there is exactly zero chance that this app will be approved for App Store. But it works using Enterprise Distribution without problems. – Tauri Jan 25 '12 at 17:23
  • Same problem. I start my app, call beginBackgroundTaskWithExpirationHandler, register keep-alive handler and move it to background, everything is fine, backgroundTimeRemaining is 600. After 600 seconds application suspend and then after few seconds keep-alive handler called. I call beginBackgroundTaskWithExpirationHandler again but backgroundTimeRemaining is only 40~60 seconds. – s.maks Apr 02 '13 at 09:14
0

This is a rather old question, but the correct way to do this now is via Background App Refresh, which is fully supported by the OS and no hacks are needed.

Note that you are still at the mercy of the OS as to when your background refresh will actually occur. You can set a minimum poll time, but there is no guarantee it will be called at that frequency as the OS will apply its own logic to optimize battery life and radio use.

The first thing you need to do is configure your project to support background refresh in the Capabilities tab. This will add the necessary keys to your Info.plist:

enter image description here

Then you need to add some stuff to your AppDelegate, which needs to implement both URLSessionDelegate and URLSessionDownloadDelegate:

private var _backgroundCompletionHandler: ((UIBackgroundFetchResult) -> Void)?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    ...

    application.setMinimumBackgroundFetchInterval(5 * 60) // set preferred minimum background poll time (no guarantee of this interval)

    ...
}

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    _backgroundCompletionHandler = completionHandler

    let sessionConfig = URLSessionConfiguration.background(withIdentifier: "com.yourapp.backgroundfetch")
    sessionConfig.sessionSendsLaunchEvents = true
    sessionConfig.isDiscretionary = true

    let session = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: OperationQueue.main)
    let task = session.downloadTask(with: url)
    task.resume()
}

func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
    NSLog("URLSession error: \(error.localizedDescription)")
    _backgroundCompletionHandler?(.failed)
    _backgroundCompletionHandler = nil
}

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
    NSLog("didFinishDownloading \(downloadTask.countOfBytesReceived) bytes to \(location)")
    var result = UIBackgroundFetchResult.noData

    do {
        let data = try Data(contentsOf: location)
        let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers)

        // process the fetched data and determine if there are new changes
        if changes {
            result = .newData

            // handle the changes here
        }
    } catch {
        result = .failed
        print("\(error.localizedDescription)")
    }

    _backgroundCompletionHandler?(result)
    _backgroundCompletionHandler = nil
}
devios1
  • 36,899
  • 45
  • 162
  • 260
0

in my tests on iOS5 i found it helps to have CoreLocation's monitoring started, via startMonitoringForLocationChangeEvents(not SignificantLocationChange), accuracy does not matter and it even this way on iPod if I do that - backgroundTimeRemaining is never goes down.

Tauri
  • 1,291
  • 1
  • 14
  • 28