Skip to content
This repository was archived by the owner on Nov 8, 2023. It is now read-only.

Android 8 Background service does not work while app is suspended #157

Closed
Raxidi opened this issue Aug 30, 2018 · 27 comments
Closed

Android 8 Background service does not work while app is suspended #157

Raxidi opened this issue Aug 30, 2018 · 27 comments
Assignees

Comments

@Raxidi
Copy link

Raxidi commented Aug 30, 2018

If the demo apps cannot help and there is no issue for your problem, tell us about it

I am using just the demo app with no changes.

Which platform(s) does your issue occur on?

  • Android
  • Android versions : 8.0 and above
  • emulator or device. What type of device? Oneplus 3T ( app works well in android 7 on emulator)

Please, provide the following version numbers that your issue occurs with:

  • CLI: 4.2.1

{
"nativescript": {
"id": "org.nativescript.LocationBG",
"tns-android": {
"version": "4.2.0"
},
"tns-ios": {
"version": "4.2.0"
}
},
"description": "NativeScript Application",
"license": "SEE LICENSE IN ",
"repository": "",
"dependencies": {
"nativescript-geolocation": "^4.3.0",
"nativescript-theme-core": "~1.0.4",
"nativescript-toast": "^1.4.6",
"tns-core-modules": "~4.2.0",
"tns-platform-declarations": "^4.2.0"
},
"devDependencies": {
"nativescript-dev-typescript": "~0.7.0",
"nativescript-dev-webpack": "~0.15.0",
"typescript": "~2.7.2"
},
"readme": "NativeScript Application"
}

Please, tell us how to recreate the issue in as much detail as possible.

Describe the steps to reproduce it.

Take the demo app from this repo. Build and install it in device having Android 8. WatchLocation fires only when app is in foreground.

This might be due to background process limitation in Android 8. I am new to Nativescript/Typescript and have no idea about using it as a Android Bound service(Android documention says Bound Service doesn't have the limitation).

I just want to get the location changes even when app is in suspended mode in Android 8.

Please help.

Is there any code involved?

@rigoparis
Copy link

rigoparis commented Sep 5, 2018

Hey,

From SDK 26 and above, the background services changed radically. The demo on this repo isn't prepared for SDK 26. I've been working on background services for this plugin for a while now. I'm using Nativescript-Vue but hopefully with the following code you will get the jest of what needs to be done.

if (application.android) {
  const { sdkVersion } = platform.device; //Here we get what SDK we are dealing with, in order to set the correct job
  if (sdkVersion * 1 < 26) {
    android.app.Service.extend("com.myAwesomeApp.location.BackgroundService", {
      onStartCommand(intent, flags, startId) {
        this.super.onStartCommand(intent, flags, startId);
        return android.app.Service.START_STICKY;
      },
      onCreate() {
        console.log("onCreate");
        //Do something
      },
      onBind() {
       // haven't had to deal with this, so i can't recall what it does
        console.log("on Bind Services");
      },
      onUnbind() {
        // haven't had to deal with this, so i can't recall what it does
        console.log("UnBind Service");
      },
      onDestroy() {
        console.log("service onDestroy");
        // end service, reset any variables... etc...
      },
    });
  } else {
    android.app.job.JobService.extend("com.myAwesomeApp.location.BackgroundService26", {
      onStartJob(params) {
        console.log("Job execution ...");
        // here you can do whatever you want
        this.jobFinished(params, true); //this ends the job if successful, if not `return false;`
      },

      onStopJob() {
        console.log("Stopping job ...");
        return true; //returning true makes the task reschedule
      },
    });
  }
}

On the manifest.xml:

<application>
         <service android:name="com.myAwesomeApp.location.BackgroundService"
    			android:exported="false"
                         android:enabled="true" />
        <service android:name="com.myAwesomeApp.location.BackgroundService26"
                        android:permission="android.permission.BIND_JOB_SERVICE"
                        android:enabled="true"
                        android:exported="false" />
</application>

From here on, you just need to call the service on any part of the app that you need...

function startBackgroundTap() {
    if (application.android) {
      const { sdkVersion } = platform.device;
      const context = utils.ad.getApplicationContext();
      if (sdkVersion * 1 < 26) {
        const intent = new android.content.Intent(context, com.myAwesomeApp.location.BackgroundService.class);
        console.log("startService");
        context.startService(intent);
      } else {
        const component = new android.content.ComponentName(context, com.myAwesomeApp.location.BackgroundService26.class);
        const builder = new android.app.job.JobInfo.Builder(1, component);
        builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY);
        builder.setPeriodic(15 * 60 * 1000);
        const jobScheduler = context.getSystemService(android.content.Context.JOB_SCHEDULER_SERVICE);
        const service = jobScheduler.schedule(builder.build());
        this.BGids.push(service);
        console.log(`Job Scheduled: ${jobScheduler.schedule(builder.build())}`);
      }
    }
  }

  function stopBackgroundTap() {
    if (application.android) {
      const { sdkVersion } = platform.device;
      const context = utils.ad.getApplicationContext();
      if (sdkVersion * 1 < 26) {
        const intent = new android.content.Intent(context, com.myAwesomeApp.location.BackgroundService.class);
        console.log("stopService");
        context.stopService(intent);
      } else {
        if (this.BGids.length > 0) {
          const jobScheduler = context.getSystemService(android.content.Context.JOB_SCHEDULER_SERVICE);
          const service = this.BGids.pop();
          jobScheduler.cancel(service);
          console.log(`Job Canceled: ${service}`);
        }
      }
    }
  }

I have a working {N}-Vue demo proyect that I could share if someone needs it, it has background services for both iOS and Android (for both pre SDK26 and post SDK26). Just let me know.

Edit: Added calling services code
Edit2: Took out HELP WANTED

@VladimirAmiorkov
Copy link
Contributor

VladimirAmiorkov commented Sep 13, 2018

Hi @Raxidi ,

Thank you for bringing this to our attention. @rigoparis Thank you for providing an solution approach, I have made changes to the demo app using this approach and a PR.

@Raxidi You can see the changes in my PR that enable the use of background service on API level 26 and above here

@Raxidi
Copy link
Author

Raxidi commented Sep 18, 2018

@rigoparis Thanks a lot for your help and guidance. I'm sorry for replying late. @VladimirAmiorkov Thank you for the updated demo app. I tried the updated version of demo background service. I'm sorry to say that location updates were not working when app was in suspended mode. It looks like any service which runs in background are affected by android power saving/doze mode optimizations.

I tried running the service as Foreground Service by creating android.app.Notification object with default constructor. Now I am able to get continuous updated in toaster alerts even when app is suspended. However I get notification alert saying "app is running in the background - Tap for details on battery and data usage"(i'm fine with this alert for my app).

I am not sure the way I use the service is good practice or not. Below are the changes I had to make to get this app running.

Here are the changes in different files:

File: background-service.ts

import * as geolocation from "nativescript-geolocation";
import { Accuracy } from "tns-core-modules/ui/enums";
import * as application from "tns-core-modules/application";
import { device } from "tns-core-modules/platform";
import * as Toast from "nativescript-toast";

let watchId;
application.on(application.exitEvent, function (args: any) {
    if (watchId) {
        geolocation.clearWatch(watchId);
    }
});

if (application.android) {
    (<any>android.app.Service).extend("com.nativescript.location.BackgroundService", {
        onStartCommand: function (intent, flags, startId) {
            this.super.onStartCommand(intent, flags, startId);
            return android.app.Service.START_STICKY;
        },
        onCreate: function () {
            if (device.sdkVersion >= "26") {
                this.startForeground(1, new android.app.Notification());
            }
            let that = this;
            geolocation.enableLocationRequest().then(function () {
                that.id = geolocation.watchLocation(
                    function (loc) {
                        if (loc) {
                            let toast = Toast.makeText('Background Location: ' + loc.latitude + ' ' + loc.longitude);
                            toast.show();
                            console.log('Background Location: ' + loc.latitude + ' ' + loc.longitude);
                        }
                    },
                    function (e) {
                        console.log("Background watchLocation error: " + (e.message || e));
                    },
                    {
                        desiredAccuracy: Accuracy.high,
                        updateDistance: 0.1,
                        updateTime: 3000,
                        minimumUpdateTime: 100
                    });
            }, function (e) {
                console.log("Background enableLocationRequest error: " + (e.message || e));
            });
        },
        onBind: function (intent) {
            console.log("on Bind Services");
        },
        onUnbind: function (intent) {
            console.log('UnBind Service');
        },
        onDestroy: function () {
            if (android.os.Build.VERSION.SDK_INT >= 26) {
                this.stopForeground(true);
            }
            console.log('service onDestroy');
            geolocation.clearWatch(this.id);
        }
    });
}

File: main-page.ts

import * as geolocation from "nativescript-geolocation";
import { Accuracy } from "ui/enums";
import { EventData } from "data/observable";
import { Page } from "ui/page";
import { MainViewModel } from "./main-view-model";
const utils = require("tns-core-modules/utils/utils");
import * as application from "tns-core-modules/application";
import * as permissions from "nativescript-permissions";

let locationService = require('./background-service');

let page: Page;
let model = new MainViewModel();
let watchIds = [];
declare var com: any;

export function pageLoaded(args: EventData) {
    page = <Page>args.object;
    page.bindingContext = model;
}

export function startBackgroundTap() {
    if (application.android) {
        if (android.os.Build.VERSION.SDK_INT >= 28) {
            permissions.requestPermission([
                "android.permission.ACCESS_FINE_LOCATION",
                "android.permission.FOREGROUND_SERVICE"
            ], "App needs the following permissions")
                .then(function (res) {
                    console.log("Permissions granted!");
                })
                .catch(function () {
                    console.log("No permissions - plan B time!");
                });
        }
        let context = utils.ad.getApplicationContext();
        let intent = new android.content.Intent(context, com.nativescript.location.BackgroundService.class);
        context.startService(intent);
    }
}

export function stopBackgroundTap() {
    if (application.android) {
        let context = utils.ad.getApplicationContext();
        let intent = new android.content.Intent(context, com.nativescript.location.BackgroundService.class);
        context.stopService(intent);
    }
}

export function enableLocationTap() {
    geolocation.isEnabled().then(function (isEnabled) {
        if (!isEnabled) {
            geolocation.enableLocationRequest().then(function () {
            }, function (e) {
                console.log("Error: " + (e.message || e));
            });
        }
    }, function (e) {
        console.log("Error: " + (e.message || e));
    });
}

export function buttonGetLocationTap() {
    let location = geolocation.getCurrentLocation({
        desiredAccuracy: Accuracy.high,
        maximumAge: 5000,
        timeout: 10000
    })
        .then(function (loc) {
            if (loc) {
                model.locations.push(loc);
            }
        }, function (e) {
            console.log("Error: " + (e.message || e));
        });
}

export function buttonStartTap() {
    try {
        watchIds.push(geolocation.watchLocation(
            function (loc) {
                if (loc) {
                    model.locations.push(loc);
                }
            },
            function (e) {
                console.log("Error: " + e.message);
            },
            {
                desiredAccuracy: Accuracy.high,
                updateDistance: 0.1,
                updateTime: 3000,
                minimumUpdateTime: 100
            }));
    } catch (ex) {
        console.log("Error: " + ex.message);
    }
}

export function buttonStopTap() {
    let watchId = watchIds.pop();
    while (watchId != null) {
        geolocation.clearWatch(watchId);
        watchId = watchIds.pop();
    }
}

export function buttonClearTap() {
    model.locations.splice(0, model.locations.length);
}

File: AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
	package="__PACKAGE__"
	android:versionCode="10000"
	android:versionName="1.0">

	<supports-screens
		android:smallScreens="true"
		android:normalScreens="true"
		android:largeScreens="true"
		android:xlargeScreens="true"/>

	<uses-sdk
		android:minSdkVersion="17"
		android:targetSdkVersion="__APILEVEL__"/>

	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
	<uses-permission android:name="android.permission.INTERNET"/>
	<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
        <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

	<application
		android:name="com.tns.NativeScriptApplication"
		android:allowBackup="true"
		android:icon="@drawable/icon"
		android:label="@string/app_name"
		android:theme="@style/AppTheme">

		<service android:name="com.nativescript.location.BackgroundService" 
			android:permission="android.permission.BIND_JOB_SERVICE"
            android:enabled="true"
            android:exported="false">
		</service>

		<activity
			android:name="com.tns.NativeScriptActivity"
			android:label="@string/title_activity_kimera"
			android:configChanges="keyboardHidden|orientation|screenSize"
			android:theme="@style/LaunchScreenTheme">

			<meta-data android:name="SET_THEME_ON_LAUNCH" android:resource="@style/AppTheme" />

			<intent-filter>
				<action android:name="android.intent.action.MAIN" />
				<category android:name="android.intent.category.LAUNCHER" />
			</intent-filter>
		</activity>
		<activity android:name="com.tns.ErrorReportActivity"/>
	</application>
</manifest>

Please let me know what I am doing is correct or not.

@zbranzov zbranzov self-assigned this Oct 2, 2018
@zbranzov
Copy link
Contributor

zbranzov commented Oct 3, 2018

Implemented in the demo app.

@zbranzov zbranzov closed this as completed Oct 3, 2018
@ghost ghost removed the ready for test label Oct 3, 2018
@andreMariano90
Copy link

Hi @rigoparis . I was just trying to work with background services in {N}-Vue and really having a hard time. Can you still share your code?

@rigoparis
Copy link

rigoparis commented Nov 6, 2018

Hey @andreMariano90 sure thing man, I'll try to upload it tomorrow since I don't have the laptop I have that in with me.

Sorry for the late response, got distracted before

@andreMariano90
Copy link

Thank you!

@rigoparis
Copy link

Hey @andreMariano90

Just uploaded the repo

https://github.com/rigoparis/GeolocationNativescriptVue

If you have any issues, please let me know... Just keep in mind that Android's background services can run at minimum every 15 minutes (up to what I've researched, this is due to battery restrictions).

Also on this demo you might encounter this error:
#134

I'll try to tweak it to bring it up to date and make everything work again 100%, but the jest remains the same and it's working on a production app I have that uses Geolocation

@rigoparis
Copy link

Hey @Raxidi

Sorry for the super late response.

I checked the code you pasted and noticed that you only have 1 service declared on the manifest

Remember to declare the 2 services like so:

<application>
         <service android:name="com.myAwesomeApp.location.BackgroundService"
    			android:exported="false"
                         android:enabled="true" />
        <service android:name="com.myAwesomeApp.location.BackgroundService26"
                        android:permission="android.permission.BIND_JOB_SERVICE"
                        android:enabled="true"
                        android:exported="false" />
</application>

That should get you up and running as everything else seems fine.

@andreMariano90
Copy link

WOW! Thank you! Sorry for restarting this closed topic!

@Raxidi
Copy link
Author

Raxidi commented Dec 26, 2018

Somehow my app crashes after certain duration of inactivity. Anyways thanks @rigoparis for all the help.

Cheers!!
Akshara

@rigoparis
Copy link

rigoparis commented Dec 26, 2018 via email

@vadimhoratiu
Copy link

vadimhoratiu commented Mar 26, 2019

If any one is interested in how to do this in the angular demo, I've done like so:
First I've created a foreground.service.android.ts file with the following code:

	@JavaProxy("com.tns.ForegroundService")
	export class ForegroundService extends android.app.Service {
		public onCreate(): void {
			super.onCreate();
		}
		public onDestroy(): void {
			super.onDestroy();
			this.stopForeground(true);
		}

		public onBind(param0: android.content.Intent): android.os.IBinder {
			console.log(param0);
			return null;
		}

		public onStartCommand(intent: android.content.Intent, flags: number, startId: number) {
			super.onStartCommand(intent, flags, startId);
			this.startForeground(1, this.createNotification(intent));
			return android.app.Service.START_STICKY
		}

		private createNotification(intent: android.content.Intent):android.app.Notification{
			this.createNotificationChannel();
			return this.getNotificationBuilder()
				.setSmallIcon(android.R.drawable.btn_plus)
				.setContentTitle(this.getTitle(intent))
				.build();
		}

		private getNotificationBuilder(){
			if(!android.support.v4.os.BuildCompat.isAtLeastO()){
				// Not Oreo, not creating notification channel as compatibility issues may exist
				return new android.support.v4.app.NotificationCompat.Builder(this);
			}

			return new android.support.v4.app.NotificationCompat.Builder(this, "TNS-ForegroundService-1");
		}

		private createNotificationChannel(){
			if(!android.support.v4.os.BuildCompat.isAtLeastO()){
				// Not Oreo, not creating notification channel as compatibility issues may exist
				return;
			}
			const importance = android.support.v4.app.NotificationManagerCompat.IMPORTANCE_LOW;
			const mChannel = new android.app.NotificationChannel("TNS-ForegroundService-1", "TNS-ForegroundService-1", importance);
			var nm = this.getSystemService(android.content.Context.NOTIFICATION_SERVICE);
			nm.createNotificationChannel(mChannel);
		}

		private getTitle(intent: android.content.Intent):string{
			var title = intent.getStringExtra("title");
			if (title) {
				return title;
			} else {
				return "Running in background";
			}
		}

		public onStart(intent: android.content.Intent, startId: number) {
			super.onStart(intent, startId);
		}
	}

Declared the service inside AndroidManifest.xml application tag as such:

<service android:name="com.tns.ForegroundService" android:exported="false" > </service>

Created a helper class to use inside standard angular components called foreground.service.util.ts
With the following code

	import * as app from "tns-core-modules/application";

	export class ForegroundUtilService {
		static startForeground() {
			if(!app.android || !app.android.context){
				return;
			}
			var foregroundNotificationIntent = new android.content.Intent();
			foregroundNotificationIntent.setClassName(app.android.context, "com.tns.ForegroundService");
			foregroundNotificationIntent.putExtra("title","Tracking...");
			app.android.context.startService(foregroundNotificationIntent);
		}

		static stopForeground() {
			var foregroundNotificationIntent = new android.content.Intent();
			foregroundNotificationIntent.setClassName(app.android.context, "com.tns.ForegroundService");
			app.android.context.stopService(foregroundNotificationIntent);
		}
	}

Added the following in home.component.ts

	import { ForegroundUtilService } from "../foreground.service.util"
	...
	public buttonStartTap() {
			try {
				ForegroundUtilService.startForeground();
	...
	public buttonStopTap() {
			ForegroundUtilService.stopForeground();
	...

@nisha-ann
Copy link

Hi @vakrilov...could you plz share this angular demo of notifications in the background.

@vakrilov
Copy link
Contributor

Hey @nisha-ann,
Perhaps you meant to ping @vadimhoratiu for the angular demo.

@nisha-ann
Copy link

yes i guess.@vadimhoratiu..could you plz share a demo in angular nativescript

@vadimhoratiu
Copy link

Hi @nisha-ann

The files that I've written above is all you need.
In short you just need to:

  1. Create a file for ForegroundService, i.e. foreground-service.ts with the code from above . This is the service that directly communicates with android to create a foreground notification
  2. Declare the com.tns.ForegroundService inside the AndroidManifest.xml (that gets generated by NativeScript) as such
  3. Create a file for the helper class ForegroundUtilService i.e. foreground.service.util.ts
  4. Call the util class inside any of your classes where you need to run tracking in the background. i.e.
    ForegroundUtilService.startForeground();

That should be it, if you still have issues I'll try to see if I still have the code somewhere and upload it here.

@nisha-ann
Copy link

Thankyou so much..let me try and I'll reply if there is any issue

@heydershukurov01
Copy link

Hi there @vadimhoratiu I've tried to use foreground but
System.err: java.lang.RuntimeException: Unable to instantiate service com.tns.ForegroundService: java.lang.ClassNotFoundException: Didn't find class "com.tns.ForegroundService"
Error Exception throws
Please help me

@vadimhoratiu
Copy link

@heydershukurov01 Hi, did you declare class
@JavaProxy("com.tns.ForegroundService") export class ForegroundService extends android.app.Service ...
And also place it inside the AndroidManifest.xml:
<service android:name="com.tns.ForegroundService" android:exported="false" > </service>
?

@d-mh
Copy link

d-mh commented Aug 1, 2019

@heydershukurov01 Have you resolved this issue java.lang.RuntimeException: Unable to instantiate service com.tns.ForegroundService: java.lang.ClassNotFoundException: Didn't find class "com.tns.ForegroundService"...? If yes could you send solution? Thanks

@nisha-ann
Copy link

nisha-ann commented Aug 1, 2019 via email

@jrkf
Copy link

jrkf commented Aug 29, 2019

Hi,
anyone fix the problem with: "Didn't find class "com.tns.ForegroundService"? In my project, I have exactly the same problem.
What is interesting that when I run my nativescript/typescript app on nativescript < 5.4 i.e. 5.1.1. my app works fine and I don't have such a problem but when I migrate to new ns 6.0.1 this problem occurs permanently

@VladimirAmiorkov
Copy link
Contributor

Hi @jrkf ,

Take a look at the current demo applications which showcase how to use a background worker on Android: https://github.com/NativeScript/nativescript-geolocation/blob/master/demo/app/background-service.ts#L42-L96

@vadimhoratiu
Copy link

vadimhoratiu commented Sep 26, 2019

Fixed for nativescript 6.1.0

Delete the following if present

  • demo-angular/platform
  • demo-angular/npm_modules
  • demo-angular/package-lock.json
  • demo-angular/hooks

Re-run npm install, tns run android

demo-angular.zip

@Basler182
Copy link

Is there a working solution for Nativescript Vuejs to foreground track the geolocation? I didnt manage to fix those old solutions. Would be awesome.

@pierreben
Copy link

I've updated the foreground service to run it on AndroidX.
I've tested it with SDK 25 to 29 with no problems:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var ForegroundService = /** @class */ (function(_super) {
  __extends(ForegroundService, _super);
  function ForegroundService() {
    return (_super !== null && _super.apply(this, arguments)) || this;
  }
  ForegroundService.prototype.onCreate = function() {
    _super.prototype.onCreate.call(this);
  };
  ForegroundService.prototype.onDestroy = function() {
    _super.prototype.onDestroy.call(this);
    this.stopForeground(true);
  };
  ForegroundService.prototype.onBind = function(param0) {
    console.log(param0);
    return null;
  };
  ForegroundService.prototype.onStartCommand = function(
    intent,
    flags,
    startId
  ) {
    _super.prototype.onStartCommand.call(this, intent, flags, startId);
    this.startForeground(1, this.createNotification(intent));
    return android.app.Service.START_STICKY;
  };
  ForegroundService.prototype.createNotification = function(intent) {
    this.createNotificationChannel();
    return this.getNotificationBuilder()
      .setSmallIcon(android.R.drawable.btn_star)
      .setContentTitle(this.getTitle(intent))
      .build();
  };
  ForegroundService.prototype.getNotificationBuilder = function() {
    if (android.os.Build.VERSION.SDK_INT < 26) {
      // Not Oreo, not creating notification channel as compatibility issues may exist
      return new androidx.core.app.NotificationCompat.Builder(this);
    }
    return new androidx.core.app.NotificationCompat.Builder(
      this,
      "TNS-ForegroundService-1"
    );
  };
  ForegroundService.prototype.createNotificationChannel = function() {
    if (android.os.Build.VERSION.SDK_INT < 26) {
      // Not Oreo, not creating notification channel as compatibility issues may exist
      return;
    }
    var importance = android.app.NotificationManager.IMPORTANCE_DEFAULT;
    var mChannel = new android.app.NotificationChannel(
      "TNS-ForegroundService-1",
      "TNS-ForegroundService-1",
      importance
    );
    var nm = this.getSystemService(
      android.content.Context.NOTIFICATION_SERVICE
    );
    nm.createNotificationChannel(mChannel);
  };
  ForegroundService.prototype.getTitle = function(intent) {
    var title = intent.getStringExtra("title");
    if (title) {
      return title;
    } else {
      return "Running in background";
    }
  };
  ForegroundService.prototype.onStart = function(intent, startId) {
    _super.prototype.onStart.call(this, intent, startId);
  };
  ForegroundService = __decorate(
    [JavaProxy("com.tns.ForegroundService")],
    ForegroundService
  );
  return ForegroundService;
})(android.app.Service);
exports.ForegroundService = ForegroundService;

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests