Search code examples
iosswiftcordovaionic-frameworkcapacitor

CapacitorJS: how to share local data between WebView and iOS widget?


I am using a widget in my capacitor app (currently this question only focuses on iOS).

I want to save an integer from my webview code into Preferences and then be able to read it from the iOS widget.

Maybe I could also use SQLIte for this as pointed out here: https://forum.ionicframework.com/t/will-app-clips-be-available-in-ionic/194768/10?u=folsze

but I think I would still need to use app groups, even for sqlite, which complicates the writing process for capacitor? Or am I wrong?

Is this even possible or am I trying to accomplish something that is impossible? (is it not possible in both iOS AND Android?). I am especially skeptic that the write from my webview code is able to write into a specifc group, since I am not sure the Preferences.ConfigureOptions.group referes to the same concept as a group for iOS app extensions

I created a group for my native app and my native widget in xcode, with the same name: enter image description here

enter image description here

I tried to do it with configureoptions group but the integer never arrives at the widget, it is non existent:

https://capacitorjs.com/docs/apis/preferences#configureoptions

Here is my webview code:

const key = 'widgetStreak';
const value = String(this.streak);
console.log("AAA", key, value);
await Preferences.configure({group: "group.com.company.name"});
await Preferences.set({ key, value });

Here is my widget code:

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), streak: 0)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), streak: fetchStreak())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, streak: fetchStreak())
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

    private func fetchStreak() -> Int {
        guard let userDefaults = UserDefaults(suiteName: "group.com.company.name") else {
            print("Failed to initialize UserDefaults.")
            return 0
        }
        
        // Ensure that UserDefaults are synchronized
        userDefaults.synchronize()
        
        // Check if the key exists
        if userDefaults.object(forKey: "widgetStreak") == nil {
            print("Key 'widgetStreak' does not exist.")
            return 0
        }
        
        let ret = userDefaults.integer(forKey: "widgetStreak")
        
        if ret == 0 {
            print("The value for 'widgetStreak' is zero.")
        } else {
            print("The value for 'widgetStreak' is: \(ret)")
        }
        
        return ret
    }

}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let streak: Int
}

struct TestWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("Daily Streak")
            Text("\(entry.streak) days")
                .font(.largeTitle)
        }
    }
}

struct TestWidget: Widget {
    let kind: String = "TestWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                TestWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                TestWidgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        .configurationDisplayName("Daily Streak Widget")
        .description("Displays your daily streak.")
    }
}

#Preview(as: .systemSmall) {
    TestWidget()
} timeline: {
    SimpleEntry(date: .now, streak: 0)
    SimpleEntry(date: .now, streak: 5)
}

Here is the output that I am getting for my native widget code:

Couldn't read values in CFPrefsPlistSource<0x101c854a0> (Domain: group.com.felixolszewski.geochamp1, User: kCFPreferencesAnyUser, ByHost: Yes, Container: (null), Contents Need Refresh: Yes): Using kCFPreferencesAnyUser with a container is only allowed for System Containers, detaching from cfprefsd
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.
Key 'widgetStreak' does not exist.

Apparently the warning can be ignored: https://stackoverflow.com/a/39923879/20009330

I also tried all the advices on this post: Failed to read values in CFPrefsPlistSource iOS 10

But none helped, still getting the Key 'widgetStreak' does not exist., which makes me think it's just not possible with Capacitor. Maybe someone could suggest some alternative workarounds? Maybe the webview localStorage can be accessed from the widget? I found some clues to this here, that localStorage or indexedDB is not a good options, for whatever reason:

https://forum.ionicframework.com/t/will-app-clips-be-available-in-ionic/194768/10?u=folsze


Solution

  • Rather than getting your JS to write the number, have your JS pass the number to a plugin on the iOS side that writes it. I have done exactly this recently and learned everything I needed from Capacitor's documentation

    https://capacitorjs.com/docs/plugins/ios

    The basic outline is

    1. Create a Swift class that does the work (in their example it is the Echo class)
    2. Create a Swift plugin that that listens to calls from Capacitor and passes that data to class #1
    3. Create a JS interface that defines the same functions as class 1
    4. Make a JS instance of the plugin by calling Capcitor's "registerPlugin"

    You then need to use Xcode's app groups to make sure both App and Widget can read the same files