capacitor: bug: service worker server.url not injecting capacitor

Bug Report

Capacitor Version

Latest Dependencies:

  @capacitor/cli: 3.3.2
  @capacitor/core: 3.3.2
  @capacitor/android: 3.3.2
  @capacitor/ios: 3.3.2

Installed Dependencies:

  @capacitor/ios: not installed
  @capacitor/cli: 3.3.2
  @capacitor/android: 3.3.2
  @capacitor/core: 3.3.2

Platform(s)

Current Behavior

Calling any native plugins when an app is served from an Angular service worker causes the plugins to fail. Using server.url to load the site on Android. image

Platform ready does not help for this.

Expected Behavior

Capacitor needs to be injected when a site is served through a service worker.

Code Reproduction

app.module.ts

export function init_app(appLoadService: AppLoadService) {
  return () => appLoadService.initApp();
}

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production,
      // Register the ServiceWorker as soon as the app is stable
      // or after 30 seconds (whichever comes first).
      registrationStrategy: 'registerWhenStable:30000',
    }),
  ],
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
    {
      provide: APP_INITIALIZER,
      useFactory: init_app,
      deps: [AppLoadService],
      multi: true,
    },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

app-load-service.ts

@Injectable({
  providedIn: 'root',
})
export class AppLoadService {
  constructor(
    private device: DeviceInfo,
    private platform: Platform
  ) {}

  async initApp(): Promise<any> {
    await this.platform.ready();

    SplashScreen.hide(); // breaks
  }
}

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 12
  • Comments: 87 (11 by maintainers)

Most upvoted comments

After working several hours on this I ended up with this solution working on capacitor 5. Hope this can help someone.

public class MainActivity extends BridgeActivity {

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

    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
      ServiceWorkerController swController = null;
      swController = ServiceWorkerController.getInstance();

      swController.setServiceWorkerClient(new ServiceWorkerClient() {
        @Override
        public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) {
          if (request.getUrl().toString().contains("index.html")) {
            request.getRequestHeaders().put("Accept", "text/html");
          }
          return bridge.getLocalServer().shouldInterceptRequest(request);
        }
      });
    }
  }
}

I am also impacted by this. Any updates on whether or not this is on the roadmap? Thanks!

Full working workaround with latest Capacitor and latest Android - compiled from ideas of @yoyo930021

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    
    if(Build.VERSION.SDK_INT >= 24 ){
      ServiceWorkerController swController = ServiceWorkerController.getInstance();

      swController.setServiceWorkerClient(new ServiceWorkerClient() {
        @Override
        public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) {
          if(request.getUrl().toString().contains("index.html")) {
            // Hack to help capacitor's "shouldInterceptRequest"
            request.getRequestHeaders().put("Accept", "text/html");
          }
          var result =  bridge.getLocalServer().shouldInterceptRequest(request);
          return result;
        }
      });
    }
  }
}

Note that depending on your SW setup root file may not be called “index.html”. In that case feel free to adjust if(request.getUrl().toString().contains("index.html")) condition above according to your app’s setup

@dylanvdmerwe Thanks for the additional context, very helpful. What I’m seeing is that SWs and caching, with Capacitor, does work in some cases, so I’m trying to narrow down the exact setup that fails and what works, so we can document it if there’s no fix we can make.

For example, I’m finding Next.js + next-pwa defaults enable this to work, and that would likely to apply to a ton of Next sites which is obviously a quite popular approach! But still interested in finding a fix for your approach

For us, the usecase is the ability to switch between different SPA application builds (e.g. test/prod, or customer1/customer2) without recompiling wrapper application.

Also, more importantly - delivering app updates quickly without recompiling / updating wrapper application on app stores (for apple store new app version validation can take time, and customers want updates ASAP)

I using this answer and work for me on android with capacitor 2. https://stackoverflow.com/questions/55894716/how-to-package-a-hosted-web-app-with-ionic-capacitor/55916297#55916297

but I replace ServiceWorkerController to ServiceWorkerControllerCompat for my case.

Is there any permanent resolution to this issue to make service workers work with capacitor plugins on Android? It is very frustrating that my Ionic6/Angular13 app works nicely with the service worker on browser but fails to register the same on Android. When I implement the workaround suggested, service worker loads but capacitor plugins fail to load on the device. Please update when there is any resolution or workaround for this.

I am also facing same issue. I have removed the service worker from my code. Still same. Any idea?

When the Android app is first installed you can see Capacitor is working: image

The site files are downloaded and served directly on first run: image

When the app is launched the second time, Capacitor is not injected: image

The files are now served from the service worker: image

image

Had this sam issue,

This worked for me. I just have to check if platform is Android, then I reloadForCapacitorInjection. Hope this helps. Thanks for all your inputs above.

`reloadForCapacitorInjection() {

const scripts = document.querySelectorAll("head script[type='text/javascript']:not([src])");
console.log('Looking for Script with Capacitor')
let matches = [];
if(scripts.length) {
  scripts.forEach((script, index) => {
    if (script && script.textContent?.includes("Capacitor")) { 
      matches.push(script);
      console.log('found Script with Capacitor')
    } else if(index+1 == scripts.length && matches.length < 1) {
      console.log('reloading no Capacitor')
      location.reload();
    }
  })
} else {
  console.log('reloading no Capacitor')
  location.reload();
}

}`

Okay, I can reproduce with that setting. I need to think more about this.

What do you think the majority of Next.js users would want for this setting? They are likely serving dynamic index content, so having dynamicStartUrl true seems like a decent middle ground to get this working. Or maybe there’s another way to inject some JS on the client that makes some call the web view can understand to inject capacitor? Not sure

In next.js or SSR, It is reasonable not to cache HTML. In SPA or offline APP, we need to cache HTML.

I think a way to inject some code in HTML. Maybe can slove it.

@dylanvdmerwe I just tested iOS using app bound domains, can confirm assets are loaded from service worker, and native plugin access works just fine.

I’m not an expert with SW’s though, only using the defaults in next-pwa: https://github.com/shadowwalker/next-pwa

But all appears to be working

@yoyo930021 with that snippet it works always. App launch, reloading the app, later app launches, etc.

Want to try my sample? https://github.com/mlynch/capacitor-remote-offline-example

On Android, I am testing this right now in Capacitor v4, Chrome WebView v101, Android 13 emulator (API 33), and this is working just fine for me:

public class MainActivity extends BridgeActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ServiceWorkerController swController = ServiceWorkerController.getInstance();

        swController.setServiceWorkerClient(new ServiceWorkerClient() {
            @Nullable
            @Override
            public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) {
                return bridge.getLocalServer().shouldInterceptRequest(request);
            }
        });
    }
}

With this I am able to load a remote URL (not localhost), and have it access plugins which are correctly defined and routed to native plugin code.

Am I missing something for why this approach isn’t working for others? cc @dylanvdmerwe

im using capacitor v4 and still got the issue, non of the solutions worked for me 😦

We’re also running into this problem. The workaround with the Plugin doesn’t seem to work for us. Not sure why, and I can’t see a way to check what goes wrong.

I had to convert the kotlin code above to java, so maybe something went wrong there? although the code does get called, when I hook the android debugger to it.

The Plugin java code:

@CapacitorPlugin
public class SetupPlugin extends Plugin {

    @Override
    public void load() {
        super.load();

        // Fix Capacitor native bridge failed when reload page with service worker
        // https://github.com/ionic-team/capacitor/issues/5278

        if (WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) {
            ServiceWorkerControllerCompat swController = ServiceWorkerControllerCompat.getInstance();

            SetupPlugin that = this;

            swController.setServiceWorkerClient(new ServiceWorkerClientCompat() {
                @Nullable
                @Override
                public WebResourceResponse shouldInterceptRequest(@NonNull WebResourceRequest request) {
                    return that.bridge.getLocalServer().shouldInterceptRequest(request);
                    // This line is called, but returns null
                }
            });
        }
    }
}

The return returns null on a request to [domain]/sw.js and I feel this is wrong?

@Aarbel @dylanvdmerwe You must to use app bound domains. https://webkit.org/blog/10882/app-bound-domains/

It doesn’t fit my needs, so I won’t research it for now

I find a solution in Android.

  1. Add a plugin in android project:
@NativePlugin
class SetupPlugin: Plugin() {
    override fun load() {
        super.load()
        // Fix Capacitor native bridge failed when reload page with service worker
        // https://github.com/ionic-team/capacitor/issues/5278
        if (WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) {
            val swController = ServiceWorkerControllerCompat.getInstance()

            swController.setServiceWorkerClient(object : ServiceWorkerClientCompat() {
                override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
                    return this@SetupPlugin.bridge.localServer.shouldInterceptRequest(request)
                }
            })
        }
    }
}

PS. ref: https://stackoverflow.com/questions/55894716/how-to-package-a-hosted-web-app-with-ionic-capacitor/55916297#55916297

  1. Add code in your service worker:
const getRequestInit = async (request: Request) => ({
  method: request.method,
  headers: request.headers,
  body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.blob(),
  referrer: request.referrer,
  referrerPolicy: request.referrerPolicy,
  mode: request.mode,
  credentials: request.credentials,
  cache: request.cache,
  redirect: request.redirect,
  integrity: request.integrity
})

const addAcceptHeaderWhenNavigate = async (req: Request) => {
  const headers = new Headers(req.headers)
  headers.set('accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
  return new Request(req.url, { ...await getRequestInit(req), headers })
}

const HTMLStrategy = (): Strategy => {
  class NetworkReplaceHost extends NetworkOnly {
    async _handle (request: Request, handler: StrategyHandler) {
      return super._handle(
        await addAcceptHeaderWhenNavigate(request),
        handler
      )
    }
  }

  return new NetworkReplaceHost()
}

registerRoute(({ request }) => request.mode === 'navigate', HTMLStrategy())

It can solve this problem. PS. I use workbox in the service worker.

I would very much appreciate an externally hosted server PWA setup to interface properly with Capacitor’s plugins on iOS and Android. I wonder if anyone above has come across any solutions?

@jcesarmobile

Any info about updates on this issue ? Can we sponsor the fix ?

Still nobody found a solution here ?

Using this kind of functionality is primarily not for the app stores in our case, we really want to use this for apps that are rolled out (enterprise) to operators and employees at our clients. This would drastically reduce the building required to deploy updates to them, as all we would need to do is update the PWA.

Please advise if there is anything that I can do to assist or test to get server.url working for Android and iOS through a service worker on Capacitor.

I’m facing this “problem” too…

As a temporary “workaround” (in order to solve this), I force a site reload when the ServiceWorker is Ready. I know and I also don’t like the solution, but I needed to implement this 💩 while this “issue/bug/feature” is addressed.

if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
  const reboot = localStorage.getItem('reboot')
  if (reboot !== '1') {
    localStorage.setItem('reboot', '1')
    setTimeout(() => location.reload(), 1)
  } else {
    localStorage.setItem('reboot', '0')
  }
}

This code is working for me, at the time as I write this… (is working on production).

Forgive me… 😂