feat(ios): add iOS platform support via Capacitor

Add iOS MVP with core functionality using Capacitor-first approach:

Platform Abstraction Layer:
- Add CapacitorPlatformService for unified platform detection
- Add PlatformCapabilities model with per-platform feature flags
- Add CapacitorNotificationService wrapping LocalNotifications plugin
- Add CapacitorReminderService for cross-platform reminder scheduling

iOS Support:
- Add Capacitor iOS project with proper configuration
- Configure Info.plist for notifications and background modes
- Add app icon and splash screen assets
- Enable CapacitorHttp for WebDAV sync (avoids CORS issues)

Refactoring:
- Update android.effects.ts to use CapacitorReminderService
- Update notify.service.ts to support iOS notifications
- Update startup.service.ts with platform-aware initialization
- Update sync-form.const.ts to hide CORS info on native platforms
- Update webdav-http-adapter.ts to use CapacitorHttp on iOS

MVP includes: task management, scheduled notifications, Dropbox sync,
WebDAV sync, share-out, dark mode. Excludes: background tracking,
widgets, local file sync, share-in (post-MVP features).
This commit is contained in:
Johannes Millan 2026-01-12 15:26:01 +01:00
parent 9d21fa1b6e
commit 590e1592da
32 changed files with 1942 additions and 109 deletions

View file

@ -2,16 +2,25 @@ import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.superproductivity.superproductivity',
appName: 'super-productivity',
appName: 'Super Productivity',
webDir: 'dist/browser',
plugins: {
CapacitorHttp: {
enabled: true,
},
LocalNotifications: {
// Android-specific: small icon for notification
smallIcon: 'ic_stat_sp',
},
},
ios: {
// Content inset for safe areas (notch, home indicator)
contentInset: 'automatic',
// Allow inline media playback
allowsLinkPreview: true,
// Scroll behavior
scrollEnabled: true,
},
};
export default config;

13
ios/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml

View file

@ -0,0 +1,408 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.superproductivity.superproductivity;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.superproductivity.superproductivity;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,49 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View file

@ -0,0 +1,14 @@
{
"images": [
{
"filename": "AppIcon-512@2x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -0,0 +1,6 @@
{
"info": {
"version": 1,
"author": "xcode"
}
}

View file

@ -0,0 +1,23 @@
{
"images": [
{
"idiom": "universal",
"filename": "splash-2732x2732-2.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "splash-2732x2732-1.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "splash-2732x2732.png",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

56
ios/App/App/Info.plist Normal file
View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Super Productivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSUserNotificationsUsageDescription</key>
<string>Super Productivity uses notifications to remind you about tasks and scheduled events.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
</dict>
</plist>

28
ios/App/Podfile Normal file
View file

@ -0,0 +1,28 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBackgroundTask', :path => '../../node_modules/@capawesome/capacitor-background-task'
end
target 'App' do
capacitor_pods
# Add your Pods here
end
post_install do |installer|
assertDeploymentTarget(installer)
end

11
package-lock.json generated
View file

@ -12,6 +12,7 @@
"packages/*"
],
"dependencies": {
"@capacitor/ios": "^7.4.4",
"electron-dl": "^3.5.2",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",
@ -5826,7 +5827,6 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.3.tgz",
"integrity": "sha512-wCWr8fQ9Wxn0466vPg7nMn0tivbNVjNy1yL4GvDSIZuZx7UpU2HeVGNe9QjN/quEd+YLRFeKEBLBw619VqUiNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
@ -5843,6 +5843,15 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.4.4.tgz",
"integrity": "sha512-Xp3bGWlSQAwsZGngRMWTdoD2agdMV12Whnm+/xsYPxfQSj+Tksbr7r/8Mso7VWkpnTKO4iMlx762g3PjW+wi4w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^7.4.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "7.0.1",
"dev": true,

View file

@ -136,6 +136,7 @@
"@ctrl/tinycolor": "4.1.0"
},
"dependencies": {
"@capacitor/ios": "^7.4.4",
"electron-dl": "^3.5.2",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.4.3",

View file

@ -5,10 +5,10 @@ import { IS_ELECTRON } from '../../app.constants';
import { IS_MOBILE } from '../../util/is-mobile';
import { TranslateService } from '@ngx-translate/core';
import { UiHelperService } from '../../features/ui-helper/ui-helper.service';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { Log } from '../log';
import { LocalNotifications } from '@capacitor/local-notifications';
import { generateNotificationId } from '../../features/android/android-notification-id.util';
import { CapacitorNotificationService } from '../platform/capacitor-notification.service';
import { CapacitorPlatformService } from '../platform/capacitor-platform.service';
@Injectable({
providedIn: 'root',
@ -16,6 +16,8 @@ import { generateNotificationId } from '../../features/android/android-notificat
export class NotifyService {
private _translateService = inject(TranslateService);
private _uiHelperService = inject(UiHelperService);
private _platformService = inject(CapacitorPlatformService);
private _notificationService = inject(CapacitorNotificationService);
async notifyDesktop(options: NotifyModel): Promise<Notification | undefined> {
if (!IS_MOBILE) {
@ -53,48 +55,29 @@ export class NotifyService {
body,
});
}
} else if (IS_ANDROID_WEB_VIEW) {
} else if (this._platformService.isNative) {
// Use Capacitor LocalNotifications for iOS and Android
try {
// Check permissions
const checkResult = await LocalNotifications.checkPermissions();
let displayPermissionGranted = checkResult.display === 'granted';
// Request permissions if not granted
if (!displayPermissionGranted) {
const requestResult = await LocalNotifications.requestPermissions();
displayPermissionGranted = requestResult.display === 'granted';
if (!displayPermissionGranted) {
Log.warn('NotifyService: Notification permission not granted');
return;
}
}
// Generate a deterministic notification ID from title and body
// Use a prefix to distinguish plugin notifications from reminders
const notificationKey = `plugin-notification:${title}:${body}`;
const notificationId = generateNotificationId(notificationKey);
// Schedule an immediate notification
await LocalNotifications.schedule({
notifications: [
{
id: notificationId,
title,
body,
schedule: {
at: new Date(Date.now() + 1000), // Show after 1 second
allowWhileIdle: true,
},
},
],
});
Log.log('NotifyService: Android notification scheduled successfully', {
const success = await this._notificationService.schedule({
id: notificationId,
title,
body,
});
if (success) {
Log.log('NotifyService: Mobile notification scheduled successfully', {
id: notificationId,
title,
platform: this._platformService.platform,
});
}
} catch (error) {
Log.err('NotifyService: Failed to show Android notification', error);
Log.err('NotifyService: Failed to show mobile notification', error);
}
} else if (this._isBasicNotificationSupport()) {
const permission = await Notification.requestPermission();
@ -122,8 +105,14 @@ export class NotifyService {
}, options.duration || 10000);
return instance;
}
} else {
Log.warn('NotifyService: No notification method available', {
platform: this._platformService.platform,
isNative: this._platformService.isNative,
hasServiceWorker: this._isServiceWorkerAvailable(),
hasBasicNotification: this._isBasicNotificationSupport(),
});
}
Log.err('No notifications supported');
return undefined;
}

View file

@ -0,0 +1,143 @@
import { TestBed } from '@angular/core/testing';
import { CapacitorNotificationService } from './capacitor-notification.service';
import { CapacitorPlatformService } from './capacitor-platform.service';
describe('CapacitorNotificationService', () => {
let service: CapacitorNotificationService;
let platformServiceSpy: jasmine.SpyObj<CapacitorPlatformService>;
beforeEach(() => {
// Create a spy for CapacitorPlatformService
platformServiceSpy = jasmine.createSpyObj(
'CapacitorPlatformService',
['hasCapability', 'isIOS', 'isAndroid'],
{
platform: 'web',
isNative: false,
isMobile: false,
capabilities: {
backgroundTracking: false,
backgroundFocusTimer: false,
localFileSync: false,
homeWidget: false,
scheduledNotifications: false,
webdavSync: true,
shareOut: true,
shareIn: false,
darkMode: true,
},
},
);
TestBed.configureTestingModule({
providers: [
CapacitorNotificationService,
{ provide: CapacitorPlatformService, useValue: platformServiceSpy },
],
});
service = TestBed.inject(CapacitorNotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('isAvailable', () => {
it('should return false when scheduledNotifications capability is false', () => {
expect(service.isAvailable).toBe(false);
});
it('should return true when scheduledNotifications capability is true', () => {
// Create a new spy with notifications enabled
const nativeServiceSpy = jasmine.createSpyObj(
'CapacitorPlatformService',
['hasCapability'],
{
capabilities: {
scheduledNotifications: true,
},
},
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
CapacitorNotificationService,
{ provide: CapacitorPlatformService, useValue: nativeServiceSpy },
],
});
const nativeService = TestBed.inject(CapacitorNotificationService);
expect(nativeService.isAvailable).toBe(true);
});
});
describe('requestPermissions', () => {
it('should return false when not available', async () => {
const result = await service.requestPermissions();
expect(result).toBe(false);
});
});
describe('checkPermissions', () => {
it('should return false when not available', async () => {
const result = await service.checkPermissions();
expect(result).toBe(false);
});
});
describe('ensurePermissions', () => {
it('should return false when not available', async () => {
const result = await service.ensurePermissions();
expect(result).toBe(false);
});
});
describe('schedule', () => {
it('should return false when not available', async () => {
const result = await service.schedule({
id: 1,
title: 'Test',
body: 'Test body',
});
expect(result).toBe(false);
});
});
describe('cancel', () => {
it('should return false when not available', async () => {
const result = await service.cancel(1);
expect(result).toBe(false);
});
});
describe('cancelMultiple', () => {
it('should return false when not available', async () => {
const result = await service.cancelMultiple([1, 2, 3]);
expect(result).toBe(false);
});
it('should return false for empty array', async () => {
const result = await service.cancelMultiple([]);
expect(result).toBe(false);
});
});
describe('getPending', () => {
it('should return empty array when not available', async () => {
const result = await service.getPending();
expect(result).toEqual([]);
});
});
describe('addActionListener', () => {
it('should not throw when not available', async () => {
await expectAsync(service.addActionListener(() => {})).toBeResolved();
});
});
describe('removeAllListeners', () => {
it('should not throw when not available', async () => {
await expectAsync(service.removeAllListeners()).toBeResolved();
});
});
});

View file

@ -0,0 +1,235 @@
import { inject, Injectable } from '@angular/core';
import { LocalNotifications, ScheduleOptions } from '@capacitor/local-notifications';
import { Log } from '../log';
import { CapacitorPlatformService } from './capacitor-platform.service';
export interface ScheduleNotificationOptions {
id: number;
title: string;
body: string;
/**
* When to show the notification. If not provided, shows immediately.
*/
scheduleAt?: Date;
/**
* Extra data to attach to the notification
*/
extra?: Record<string, unknown>;
/**
* Whether to allow notification when device is idle (Android)
*/
allowWhileIdle?: boolean;
}
/**
* Service for managing local notifications via Capacitor.
*
* This service provides a unified interface for scheduling and canceling
* local notifications on iOS and Android.
*/
@Injectable({
providedIn: 'root',
})
export class CapacitorNotificationService {
private _platformService = inject(CapacitorPlatformService);
/**
* Check if notifications are available on this platform
*/
get isAvailable(): boolean {
return this._platformService.capabilities.scheduledNotifications;
}
/**
* Request notification permissions from the user
*/
async requestPermissions(): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
const result = await LocalNotifications.requestPermissions();
return result.display === 'granted';
} catch (error) {
Log.err('CapacitorNotificationService: Failed to request permissions', error);
return false;
}
}
/**
* Check current notification permission status
*/
async checkPermissions(): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
const result = await LocalNotifications.checkPermissions();
return result.display === 'granted';
} catch (error) {
Log.err('CapacitorNotificationService: Failed to check permissions', error);
return false;
}
}
/**
* Ensure permissions are granted, requesting if necessary
*/
async ensurePermissions(): Promise<boolean> {
const hasPermission = await this.checkPermissions();
if (hasPermission) {
return true;
}
return this.requestPermissions();
}
/**
* Schedule a local notification
*/
async schedule(options: ScheduleNotificationOptions): Promise<boolean> {
if (!this.isAvailable) {
Log.warn(
'CapacitorNotificationService: Notifications not available on this platform',
);
return false;
}
const hasPermission = await this.ensurePermissions();
if (!hasPermission) {
Log.warn('CapacitorNotificationService: Notification permission not granted');
return false;
}
try {
const scheduleOptions: ScheduleOptions = {
notifications: [
{
id: options.id,
title: options.title,
body: options.body,
extra: options.extra,
schedule: options.scheduleAt
? {
at: options.scheduleAt,
allowWhileIdle: options.allowWhileIdle ?? true,
}
: {
// Schedule for 1 second from now for immediate notifications
at: new Date(Date.now() + 1000),
allowWhileIdle: options.allowWhileIdle ?? true,
},
},
],
};
await LocalNotifications.schedule(scheduleOptions);
Log.log('CapacitorNotificationService: Notification scheduled', {
id: options.id,
title: options.title,
scheduleAt: options.scheduleAt,
});
return true;
} catch (error) {
Log.err('CapacitorNotificationService: Failed to schedule notification', error);
return false;
}
}
/**
* Cancel a scheduled notification by ID
*/
async cancel(notificationId: number): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
await LocalNotifications.cancel({
notifications: [{ id: notificationId }],
});
Log.log('CapacitorNotificationService: Notification cancelled', {
id: notificationId,
});
return true;
} catch (error) {
Log.err('CapacitorNotificationService: Failed to cancel notification', error);
return false;
}
}
/**
* Cancel multiple scheduled notifications
*/
async cancelMultiple(notificationIds: number[]): Promise<boolean> {
if (!this.isAvailable || notificationIds.length === 0) {
return false;
}
try {
await LocalNotifications.cancel({
notifications: notificationIds.map((id) => ({ id })),
});
Log.log('CapacitorNotificationService: Notifications cancelled', {
ids: notificationIds,
});
return true;
} catch (error) {
Log.err('CapacitorNotificationService: Failed to cancel notifications', error);
return false;
}
}
/**
* Get all pending notifications
*/
async getPending(): Promise<{ id: number }[]> {
if (!this.isAvailable) {
return [];
}
try {
const result = await LocalNotifications.getPending();
return result.notifications;
} catch (error) {
Log.err('CapacitorNotificationService: Failed to get pending notifications', error);
return [];
}
}
/**
* Register a listener for notification actions
*/
async addActionListener(
callback: (notification: { id: number; extra?: Record<string, unknown> }) => void,
): Promise<void> {
if (!this.isAvailable) {
return;
}
await LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
callback({
id: event.notification.id,
extra: event.notification.extra,
});
});
}
/**
* Remove all notification listeners
*/
async removeAllListeners(): Promise<void> {
if (!this.isAvailable) {
return;
}
await LocalNotifications.removeAllListeners();
}
}

View file

@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { CapacitorPlatformService } from './capacitor-platform.service';
describe('CapacitorPlatformService', () => {
let service: CapacitorPlatformService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CapacitorPlatformService],
});
service = TestBed.inject(CapacitorPlatformService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should detect platform type', () => {
expect(service.platform).toBeDefined();
expect(['ios', 'android', 'web', 'electron']).toContain(service.platform);
});
it('should have capabilities object', () => {
expect(service.capabilities).toBeDefined();
expect(typeof service.capabilities.scheduledNotifications).toBe('boolean');
expect(typeof service.capabilities.webdavSync).toBe('boolean');
expect(typeof service.capabilities.shareOut).toBe('boolean');
});
it('should have consistent platform checks', () => {
// Only one platform method should return true
const platformChecks = [
service.isIOS(),
service.isAndroid(),
service.isElectron(),
service.isWeb(),
];
const trueCount = platformChecks.filter((x) => x).length;
expect(trueCount).toBe(1);
});
it('should check capability via hasCapability method', () => {
expect(service.hasCapability('webdavSync')).toBe(service.capabilities.webdavSync);
expect(service.hasCapability('scheduledNotifications')).toBe(
service.capabilities.scheduledNotifications,
);
});
describe('in web environment', () => {
// These tests run in Karma which is a web browser
it('should detect web platform in test environment', () => {
// In Karma test runner, we're in a web context
expect(service.platform).toBe('web');
expect(service.isWeb()).toBe(true);
expect(service.isNative).toBe(false);
});
it('should have web capabilities', () => {
expect(service.capabilities.backgroundTracking).toBe(false);
expect(service.capabilities.localFileSync).toBe(false);
expect(service.capabilities.webdavSync).toBe(true);
});
});
});

View file

@ -0,0 +1,169 @@
import { Injectable } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import {
ANDROID_CAPABILITIES,
ELECTRON_CAPABILITIES,
IOS_CAPABILITIES,
PlatformCapabilities,
PlatformType,
WEB_CAPABILITIES,
} from './platform-capabilities.model';
/**
* Service for detecting platform and exposing platform-specific capabilities.
*
* This service provides a unified interface for platform detection and feature
* availability, replacing scattered IS_ANDROID_WEB_VIEW checks throughout the codebase.
*
* Usage:
* ```typescript
* if (this.platformService.capabilities.scheduledNotifications) {
* // Schedule notification using Capacitor plugin
* }
* ```
*/
@Injectable({
providedIn: 'root',
})
export class CapacitorPlatformService {
/**
* The current platform type
*/
readonly platform: PlatformType;
/**
* Whether running in a native Capacitor context (iOS or Android)
*/
readonly isNative: boolean;
/**
* Whether running in a mobile context (iOS or Android, native or PWA)
*/
readonly isMobile: boolean;
/**
* Platform capabilities for conditional feature enabling
*/
readonly capabilities: PlatformCapabilities;
constructor() {
this.platform = this._detectPlatform();
// Include legacy Android WebView in isNative check
this.isNative = Capacitor.isNativePlatform() || this._isAndroidWebView();
this.isMobile = this.platform === 'ios' || this.platform === 'android';
this.capabilities = this._getCapabilities();
}
/**
* Check if a specific capability is available
*/
hasCapability(capability: keyof PlatformCapabilities): boolean {
return this.capabilities[capability];
}
/**
* Check if running on iOS
*/
isIOS(): boolean {
return this.platform === 'ios';
}
/**
* Check if running on Android
*/
isAndroid(): boolean {
return this.platform === 'android';
}
/**
* Check if running in Electron
*/
isElectron(): boolean {
return this.platform === 'electron';
}
/**
* Check if running in web browser (not Electron, not native mobile)
*/
isWeb(): boolean {
return this.platform === 'web';
}
/**
* Detect the current platform
*/
private _detectPlatform(): PlatformType {
// Check for Electron first (it also has navigator)
if (this._isElectron()) {
return 'electron';
}
// Check for native Capacitor platforms
if (Capacitor.isNativePlatform()) {
const platform = Capacitor.getPlatform();
if (platform === 'ios') {
return 'ios';
}
if (platform === 'android') {
return 'android';
}
}
// Check for Android WebView (legacy check for existing Android implementation)
if (this._isAndroidWebView()) {
return 'android';
}
// Check for iOS in browser context (iPad, iPhone)
if (this._isIOSBrowser()) {
// Running in iOS browser, not native - treat as web
return 'web';
}
return 'web';
}
/**
* Get capabilities for the current platform
*/
private _getCapabilities(): PlatformCapabilities {
switch (this.platform) {
case 'ios':
return IOS_CAPABILITIES;
case 'android':
return ANDROID_CAPABILITIES;
case 'electron':
return ELECTRON_CAPABILITIES;
default:
return WEB_CAPABILITIES;
}
}
/**
* Check if running in Electron
*/
private _isElectron(): boolean {
return navigator.userAgent.toLowerCase().indexOf(' electron/') > -1;
}
/**
* Check if running in Android WebView (legacy detection)
*/
private _isAndroidWebView(): boolean {
return !!(window as any).SUPAndroid;
}
/**
* Check if running in iOS browser (not native)
*/
private _isIOSBrowser(): boolean {
// Check user agent for iOS devices
const userAgent = navigator.userAgent;
const isIOSUserAgent =
/iPad|iPhone|iPod/.test(userAgent) ||
// iPad on iOS 13+ reports as Mac with touch support
(userAgent.includes('Mac') && 'ontouchend' in document);
return isIOSUserAgent;
}
}

View file

@ -0,0 +1,185 @@
import { TestBed } from '@angular/core/testing';
import { CapacitorReminderService } from './capacitor-reminder.service';
import { CapacitorPlatformService } from './capacitor-platform.service';
import { CapacitorNotificationService } from './capacitor-notification.service';
describe('CapacitorReminderService', () => {
let service: CapacitorReminderService;
let platformServiceSpy: jasmine.SpyObj<CapacitorPlatformService>;
let notificationServiceSpy: jasmine.SpyObj<CapacitorNotificationService>;
beforeEach(() => {
// Create spies for dependencies
platformServiceSpy = jasmine.createSpyObj(
'CapacitorPlatformService',
['hasCapability', 'isIOS', 'isAndroid'],
{
platform: 'web',
isNative: false,
isMobile: false,
capabilities: {
backgroundTracking: false,
backgroundFocusTimer: false,
localFileSync: false,
homeWidget: false,
scheduledNotifications: false,
webdavSync: true,
shareOut: true,
shareIn: false,
darkMode: true,
},
},
);
notificationServiceSpy = jasmine.createSpyObj('CapacitorNotificationService', [
'ensurePermissions',
'cancel',
'cancelMultiple',
]);
notificationServiceSpy.ensurePermissions.and.returnValue(Promise.resolve(true));
notificationServiceSpy.cancel.and.returnValue(Promise.resolve(true));
notificationServiceSpy.cancelMultiple.and.returnValue(Promise.resolve(true));
TestBed.configureTestingModule({
providers: [
CapacitorReminderService,
{ provide: CapacitorPlatformService, useValue: platformServiceSpy },
{ provide: CapacitorNotificationService, useValue: notificationServiceSpy },
],
});
service = TestBed.inject(CapacitorReminderService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('isAvailable', () => {
it('should return false when scheduledNotifications capability is false', () => {
expect(service.isAvailable).toBe(false);
});
it('should return true when scheduledNotifications capability is true', () => {
const nativePlatformSpy = jasmine.createSpyObj(
'CapacitorPlatformService',
['hasCapability', 'isIOS'],
{
platform: 'ios',
isNative: true,
capabilities: {
scheduledNotifications: true,
},
},
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
CapacitorReminderService,
{ provide: CapacitorPlatformService, useValue: nativePlatformSpy },
{ provide: CapacitorNotificationService, useValue: notificationServiceSpy },
],
});
const nativeService = TestBed.inject(CapacitorReminderService);
expect(nativeService.isAvailable).toBe(true);
});
});
describe('scheduleReminder', () => {
it('should return false when not available', async () => {
const result = await service.scheduleReminder({
notificationId: 1,
reminderId: 'task-1',
relatedId: 'task-1',
title: 'Test Reminder',
reminderType: 'TASK',
triggerAtMs: Date.now() + 60000,
});
expect(result).toBe(false);
});
});
describe('cancelReminder', () => {
it('should return false when not available', async () => {
const result = await service.cancelReminder(1);
expect(result).toBe(false);
});
});
describe('cancelMultipleReminders', () => {
it('should return false when not available', async () => {
const result = await service.cancelMultipleReminders([1, 2, 3]);
expect(result).toBe(false);
});
it('should return false for empty array', async () => {
const result = await service.cancelMultipleReminders([]);
expect(result).toBe(false);
});
});
describe('ensurePermissions', () => {
it('should return false when not available', async () => {
const result = await service.ensurePermissions();
expect(result).toBe(false);
});
});
describe('with native platform', () => {
let nativeService: CapacitorReminderService;
let nativePlatformSpy: jasmine.SpyObj<CapacitorPlatformService>;
beforeEach(() => {
nativePlatformSpy = jasmine.createSpyObj(
'CapacitorPlatformService',
['hasCapability', 'isIOS'],
{
platform: 'ios',
isNative: true,
isMobile: true,
capabilities: {
backgroundTracking: false,
backgroundFocusTimer: false,
localFileSync: false,
homeWidget: false,
scheduledNotifications: true,
webdavSync: true,
shareOut: true,
shareIn: false,
darkMode: true,
},
},
);
nativePlatformSpy.isIOS.and.returnValue(true);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
CapacitorReminderService,
{ provide: CapacitorPlatformService, useValue: nativePlatformSpy },
{ provide: CapacitorNotificationService, useValue: notificationServiceSpy },
],
});
nativeService = TestBed.inject(CapacitorReminderService);
});
it('should be available on native platform', () => {
expect(nativeService.isAvailable).toBe(true);
});
it('should call notificationService.cancel when cancelling reminder', async () => {
await nativeService.cancelReminder(123);
expect(notificationServiceSpy.cancel).toHaveBeenCalledWith(123);
});
it('should call notificationService.cancelMultiple when cancelling multiple reminders', async () => {
await nativeService.cancelMultipleReminders([1, 2, 3]);
expect(notificationServiceSpy.cancelMultiple).toHaveBeenCalledWith([1, 2, 3]);
});
it('should call notificationService.ensurePermissions when ensuring permissions', async () => {
await nativeService.ensurePermissions();
expect(notificationServiceSpy.ensurePermissions).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,228 @@
import { inject, Injectable } from '@angular/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { Log } from '../log';
import { CapacitorPlatformService } from './capacitor-platform.service';
import { CapacitorNotificationService } from './capacitor-notification.service';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { androidInterface } from '../../features/android/android-interface';
export interface ScheduleReminderOptions {
/**
* Unique notification ID (numeric)
*/
notificationId: number;
/**
* Reminder identifier (string, e.g., task ID)
*/
reminderId: string;
/**
* Related entity ID (string, e.g., task ID)
*/
relatedId: string;
/**
* Notification title
*/
title: string;
/**
* Type of reminder (e.g., 'TASK')
*/
reminderType: string;
/**
* Timestamp when the reminder should trigger (in milliseconds)
*/
triggerAtMs: number;
}
/**
* Service for scheduling reminders across platforms.
*
* On Android: Uses native AlarmManager via androidInterface for precise timing
* On iOS: Uses Capacitor LocalNotifications for scheduled notifications
*/
@Injectable({
providedIn: 'root',
})
export class CapacitorReminderService {
private _platformService = inject(CapacitorPlatformService);
private _notificationService = inject(CapacitorNotificationService);
/**
* Check if reminder scheduling is available on this platform
*/
get isAvailable(): boolean {
return this._platformService.capabilities.scheduledNotifications;
}
/**
* Schedule a reminder notification
*/
async scheduleReminder(options: ScheduleReminderOptions): Promise<boolean> {
if (!this.isAvailable) {
Log.warn('CapacitorReminderService: Scheduled notifications not available');
return false;
}
const now = Date.now();
const triggerAt = options.triggerAtMs <= now ? now + 1000 : options.triggerAtMs;
// On Android, use native AlarmManager for precision
if (IS_ANDROID_WEB_VIEW && androidInterface.scheduleNativeReminder) {
try {
androidInterface.scheduleNativeReminder(
options.notificationId,
options.reminderId,
options.relatedId,
options.title,
options.reminderType,
triggerAt,
);
Log.log('CapacitorReminderService: Android reminder scheduled', {
notificationId: options.notificationId,
title: options.title,
triggerAt: new Date(triggerAt).toISOString(),
});
return true;
} catch (error) {
Log.err('CapacitorReminderService: Failed to schedule Android reminder', error);
return false;
}
}
// On iOS (and Android as fallback), use Capacitor LocalNotifications
if (this._platformService.isNative) {
try {
const hasPermission = await this._notificationService.ensurePermissions();
if (!hasPermission) {
Log.warn('CapacitorReminderService: Notification permission not granted');
return false;
}
await LocalNotifications.schedule({
notifications: [
{
id: options.notificationId,
title: options.title,
body: `Reminder: ${options.title}`,
schedule: {
at: new Date(triggerAt),
allowWhileIdle: true,
},
extra: {
reminderId: options.reminderId,
relatedId: options.relatedId,
reminderType: options.reminderType,
},
},
],
});
Log.log('CapacitorReminderService: iOS reminder scheduled', {
notificationId: options.notificationId,
title: options.title,
triggerAt: new Date(triggerAt).toISOString(),
});
return true;
} catch (error) {
Log.err('CapacitorReminderService: Failed to schedule iOS reminder', error);
return false;
}
}
Log.warn('CapacitorReminderService: No reminder implementation for platform', {
platform: this._platformService.platform,
});
return false;
}
/**
* Cancel a scheduled reminder
*/
async cancelReminder(notificationId: number): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
// On Android, use native cancellation
if (IS_ANDROID_WEB_VIEW && androidInterface.cancelNativeReminder) {
try {
androidInterface.cancelNativeReminder(notificationId);
Log.log('CapacitorReminderService: Android reminder cancelled', {
notificationId,
});
return true;
} catch (error) {
Log.err('CapacitorReminderService: Failed to cancel Android reminder', error);
return false;
}
}
// On iOS (and Android as fallback), use Capacitor LocalNotifications
if (this._platformService.isNative) {
return this._notificationService.cancel(notificationId);
}
return false;
}
/**
* Cancel multiple scheduled reminders
*/
async cancelMultipleReminders(notificationIds: number[]): Promise<boolean> {
if (!this.isAvailable || notificationIds.length === 0) {
return false;
}
// On Android, cancel each individually
if (IS_ANDROID_WEB_VIEW && androidInterface.cancelNativeReminder) {
try {
for (const id of notificationIds) {
androidInterface.cancelNativeReminder(id);
}
Log.log('CapacitorReminderService: Android reminders cancelled', {
count: notificationIds.length,
});
return true;
} catch (error) {
Log.err('CapacitorReminderService: Failed to cancel Android reminders', error);
return false;
}
}
// On iOS (and Android as fallback), use batch cancellation
if (this._platformService.isNative) {
return this._notificationService.cancelMultiple(notificationIds);
}
return false;
}
/**
* Ensure notification permissions are granted.
* Also handles Android 12+ exact alarm permissions.
*/
async ensurePermissions(): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
const hasPermission = await this._notificationService.ensurePermissions();
if (!hasPermission) {
return false;
}
// On Android 12+, also check exact alarm permission
if (IS_ANDROID_WEB_VIEW) {
try {
const exactAlarmStatus = await LocalNotifications.checkExactNotificationSetting();
if (exactAlarmStatus?.exact_alarm !== 'granted') {
await LocalNotifications.changeExactNotificationSetting();
}
} catch (error) {
// Non-fatal - exact alarms may not be available on all devices
Log.warn('CapacitorReminderService: Exact alarm check failed', error);
}
}
return true;
}
}

View file

@ -0,0 +1,4 @@
export * from './platform-capabilities.model';
export * from './capacitor-platform.service';
export * from './capacitor-notification.service';
export * from './capacitor-reminder.service';

View file

@ -0,0 +1,124 @@
/**
* Platform types supported by the application
*/
export type PlatformType = 'ios' | 'android' | 'web' | 'electron';
/**
* Runtime capabilities that vary by platform.
* Used to conditionally enable/disable features in the UI.
*/
export interface PlatformCapabilities {
/**
* Whether the platform supports background time tracking notifications.
* Android only - uses foreground service.
*/
readonly backgroundTracking: boolean;
/**
* Whether the platform supports background focus mode timer.
* Android only - uses foreground service.
*/
readonly backgroundFocusTimer: boolean;
/**
* Whether the platform supports local file sync (Storage Access Framework).
* Android only for MVP.
*/
readonly localFileSync: boolean;
/**
* Whether the platform supports home screen widgets.
* Android only for MVP.
*/
readonly homeWidget: boolean;
/**
* Whether the platform supports scheduled local notifications.
* iOS and Android via Capacitor LocalNotifications plugin.
*/
readonly scheduledNotifications: boolean;
/**
* Whether the platform supports WebDAV sync.
* All platforms - uses CapacitorHttp on mobile, fetch on web.
*/
readonly webdavSync: boolean;
/**
* Whether the platform supports sharing content to other apps.
* iOS and Android via Capacitor Share plugin, Web via Web Share API.
*/
readonly shareOut: boolean;
/**
* Whether the platform supports receiving shared content from other apps.
* Android only for MVP. iOS requires Share Extension (post-MVP).
*/
readonly shareIn: boolean;
/**
* Whether the platform supports native dark mode detection.
* All platforms support this.
*/
readonly darkMode: boolean;
}
/**
* Default capabilities for web browser
*/
export const WEB_CAPABILITIES: PlatformCapabilities = {
backgroundTracking: false,
backgroundFocusTimer: false,
localFileSync: false,
homeWidget: false,
scheduledNotifications: false, // Service worker notifications only
webdavSync: true,
shareOut: true, // Web Share API
shareIn: false,
darkMode: true,
};
/**
* Capabilities for Electron desktop app
*/
export const ELECTRON_CAPABILITIES: PlatformCapabilities = {
backgroundTracking: true, // Tray icon
backgroundFocusTimer: true, // Always visible window
localFileSync: true, // Native file system
homeWidget: false,
scheduledNotifications: true, // Native notifications
webdavSync: true,
shareOut: true,
shareIn: false,
darkMode: true,
};
/**
* Capabilities for Android via Capacitor
*/
export const ANDROID_CAPABILITIES: PlatformCapabilities = {
backgroundTracking: true, // Foreground service
backgroundFocusTimer: true, // Foreground service
localFileSync: true, // Storage Access Framework
homeWidget: true, // AppWidgetProvider
scheduledNotifications: true, // LocalNotifications plugin
webdavSync: true, // CapacitorHttp
shareOut: true, // Share plugin
shareIn: true, // Intent filter
darkMode: true,
};
/**
* Capabilities for iOS via Capacitor (MVP)
*/
export const IOS_CAPABILITIES: PlatformCapabilities = {
backgroundTracking: false, // Not supported in MVP
backgroundFocusTimer: false, // Not supported in MVP
localFileSync: false, // Not supported in MVP
homeWidget: false, // Not supported in MVP
scheduledNotifications: true, // LocalNotifications plugin
webdavSync: true, // CapacitorHttp
shareOut: true, // Share plugin
shareIn: false, // Requires Share Extension (post-MVP)
darkMode: true,
};

View file

@ -11,7 +11,6 @@ import { BannerService } from '../banner/banner.service';
import { UiHelperService } from '../../features/ui-helper/ui-helper.service';
import { ChromeExtensionInterfaceService } from '../chrome-extension-interface/chrome-extension-interface.service';
import { ProjectService } from '../../features/project/project.service';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { IS_ELECTRON } from '../../app.constants';
import { Log } from '../log';
import { T } from '../../t.const';
@ -27,6 +26,7 @@ import { IPC } from '../../../../electron/shared-with-frontend/ipc-events.const'
import { IpcRendererEvent } from 'electron';
import { environment } from '../../../environments/environment';
import { TrackingReminderService } from '../../features/tracking-reminder/tracking-reminder.service';
import { CapacitorPlatformService } from '../platform/capacitor-platform.service';
const w = window as Window & { productivityTips?: string[][]; randomIndex?: number };
@ -51,6 +51,7 @@ export class StartupService {
private _projectService = inject(ProjectService);
private _trackingReminderService = inject(TrackingReminderService);
private _opLogStore = inject(OperationLogStoreService);
private _platformService = inject(CapacitorPlatformService);
constructor() {
// Initialize electron error handler in an effect
@ -77,7 +78,8 @@ export class StartupService {
}
async init(): Promise<void> {
if (!IS_ANDROID_WEB_VIEW && !IS_ELECTRON) {
// Skip single instance check for native mobile apps and Electron
if (!this._platformService.isNative && !IS_ELECTRON) {
const isSingle = await this._checkIsSingleInstance();
if (!isSingle) {
this._showMultiInstanceBlocker();
@ -140,7 +142,8 @@ export class StartupService {
}
});
if (!IS_ANDROID_WEB_VIEW) {
// Chrome extension only works in web browser, not native mobile apps
if (!this._platformService.isNative) {
this._chromeExtensionInterfaceService.init();
}
}
@ -148,7 +151,8 @@ export class StartupService {
private async _initBackups(): Promise<void> {
// if completely fresh instance check for local backups
if (IS_ELECTRON || IS_ANDROID_WEB_VIEW) {
// Local backups are available on Electron and native mobile (iOS/Android)
if (IS_ELECTRON || this._platformService.isNative) {
const stateCache = await this._opLogStore.loadStateCache();
// If no state cache exists, this is a fresh instance - offer to restore from backup
if (!stateCache) {
@ -258,8 +262,8 @@ export class StartupService {
if (granted) {
Log.log('Persistent store granted');
}
// NOTE: we never execute for android web view, because it is always true
else if (!IS_ANDROID_WEB_VIEW) {
// NOTE: we never show this warning for native mobile apps, because persistence is always granted
else if (!this._platformService.isNative) {
const msg = T.GLOBAL_SNACK.PERSISTENCE_DISALLOWED;
Log.warn('Persistence not allowed');
this._snackService.open({ msg });

View file

@ -2,7 +2,6 @@ import { inject, Injectable } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { switchMap, tap } from 'rxjs/operators';
import { timer } from 'rxjs';
import { LocalNotifications } from '@capacitor/local-notifications';
import { SnackService } from '../../../core/snack/snack.service';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { DroidLog } from '../../../core/log';
@ -12,6 +11,8 @@ import { TaskService } from '../../tasks/task.service';
import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service';
import { Store } from '@ngrx/store';
import { selectAllTasksWithReminder } from '../../tasks/store/task.selectors';
import { CapacitorReminderService } from '../../../core/platform/capacitor-reminder.service';
import { CapacitorPlatformService } from '../../../core/platform/capacitor-platform.service';
// TODO send message to electron when current task changes here
@ -24,28 +25,29 @@ export class AndroidEffects {
private _taskService = inject(TaskService);
private _taskAttachmentService = inject(TaskAttachmentService);
private _store = inject(Store);
private _reminderService = inject(CapacitorReminderService);
private _platformService = inject(CapacitorPlatformService);
// Single-shot guard so we don't spam the user with duplicate warnings.
private _hasShownNotificationWarning = false;
private _hasCheckedExactAlarm = false;
// Track scheduled reminder IDs to cancel removed ones
private _scheduledReminderIds = new Set<string>();
/**
* Check notification permissions on startup for mobile platforms.
* Shows a warning if permissions are not granted.
*/
askPermissionsIfNotGiven$ =
IS_ANDROID_WEB_VIEW &&
this._platformService.isNative &&
createEffect(
() =>
timer(DELAY_PERMISSIONS).pipe(
tap(async (v) => {
tap(async () => {
try {
const checkResult = await LocalNotifications.checkPermissions();
DroidLog.log('AndroidEffects: initial permission check', checkResult);
const displayPermissionGranted = checkResult.display === 'granted';
if (displayPermissionGranted) {
await this._ensureExactAlarmAccess();
return;
const hasPermission = await this._reminderService.ensurePermissions();
DroidLog.log('MobileEffects: initial permission check', { hasPermission });
if (!hasPermission) {
this._notifyPermissionIssue();
}
// Surface a gentle warning early, but defer the actual permission prompt until we truly need it.
this._notifyPermissionIssue();
} catch (error) {
DroidLog.err(error);
this._notifyPermissionIssue(error?.toString());
@ -58,16 +60,17 @@ export class AndroidEffects {
);
/**
* Schedule native Android reminders for tasks with remindAt set.
* Schedule reminders for tasks with remindAt set.
* Works on both iOS and Android.
*
* SYNC-SAFE: This effect is intentionally safe during sync/hydration because:
* - dispatch: false - no store mutations, only native Android API calls
* - dispatch: false - no store mutations, only native API calls
* - We WANT notifications scheduled for synced tasks (user-facing functionality)
* - Native AlarmManager calls are idempotent - rescheduling the same reminder is harmless
* - Native scheduling calls are idempotent - rescheduling the same reminder is harmless
* - Cancellation of removed reminders correctly handles tasks deleted via sync
*/
scheduleNotifications$ =
IS_ANDROID_WEB_VIEW &&
this._platformService.isNative &&
createEffect(
() =>
timer(DELAY_SCHEDULE).pipe(
@ -82,11 +85,11 @@ export class AndroidEffects {
for (const previousId of this._scheduledReminderIds) {
if (!currentReminderIds.has(previousId)) {
const notificationId = generateNotificationId(previousId);
DroidLog.log('AndroidEffects: cancelling removed reminder', {
DroidLog.log('MobileEffects: cancelling removed reminder', {
relatedId: previousId,
notificationId,
});
androidInterface.cancelNativeReminder?.(notificationId);
await this._reminderService.cancelReminder(notificationId);
}
}
@ -94,44 +97,38 @@ export class AndroidEffects {
this._scheduledReminderIds.clear();
return;
}
DroidLog.log('AndroidEffects: scheduling reminders natively', {
DroidLog.log('MobileEffects: scheduling reminders', {
reminderCount: tasksWithReminders.length,
platform: this._platformService.platform,
});
// Check permissions first
const checkResult = await LocalNotifications.checkPermissions();
let displayPermissionGranted = checkResult.display === 'granted';
if (!displayPermissionGranted) {
const requestResult = await LocalNotifications.requestPermissions();
displayPermissionGranted = requestResult.display === 'granted';
if (!displayPermissionGranted) {
this._notifyPermissionIssue();
return;
}
// Ensure permissions are granted
const hasPermission = await this._reminderService.ensurePermissions();
if (!hasPermission) {
this._notifyPermissionIssue();
return;
}
await this._ensureExactAlarmAccess();
// Schedule each reminder using native Android AlarmManager
// Schedule each reminder using the platform-appropriate method
for (const task of tasksWithReminders) {
const id = generateNotificationId(task.id);
const now = Date.now();
const scheduleAt = task.remindAt! <= now ? now + 1000 : task.remindAt!;
androidInterface.scheduleNativeReminder?.(
id,
task.id,
task.id,
task.title,
'TASK',
scheduleAt,
);
await this._reminderService.scheduleReminder({
notificationId: id,
reminderId: task.id,
relatedId: task.id,
title: task.title,
reminderType: 'TASK',
triggerAtMs: task.remindAt!,
});
}
// Update tracked IDs
this._scheduledReminderIds = currentReminderIds;
DroidLog.log('AndroidEffects: scheduled native reminders', {
DroidLog.log('MobileEffects: scheduled reminders', {
reminderCount: tasksWithReminders.length,
platform: this._platformService.platform,
});
} catch (error) {
DroidLog.err(error);
@ -210,23 +207,6 @@ export class AndroidEffects {
{ dispatch: false },
);
private async _ensureExactAlarmAccess(): Promise<void> {
try {
if (this._hasCheckedExactAlarm) {
return;
}
// Android 12+ gates exact alarms behind a separate toggle; surface the settings screen if needed.
const exactAlarmStatus = await LocalNotifications.checkExactNotificationSetting();
if (exactAlarmStatus?.exact_alarm !== 'granted') {
DroidLog.log(await LocalNotifications.changeExactNotificationSetting());
} else {
this._hasCheckedExactAlarm = true;
}
} catch (error) {
DroidLog.warn(error);
}
}
private _notifyPermissionIssue(message?: string): void {
if (this._hasShownNotificationWarning) {
return;

View file

@ -6,6 +6,7 @@ import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IS_ELECTRON } from '../../../app.constants';
import { fileSyncDroid, fileSyncElectron } from '../../../op-log/model/model-config';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
/**
* Creates form fields for WebDAV-based sync providers.
@ -19,7 +20,8 @@ const createWebdavFormFields = (options: {
baseUrlDescription: string;
}): FormlyFieldConfig[] => {
return [
...(!IS_ELECTRON && !IS_ANDROID_WEB_VIEW
// Hide CORS info for Electron and native mobile apps (iOS/Android) since they handle CORS natively
...(!IS_ELECTRON && !IS_NATIVE_PLATFORM
? [
{
type: 'tpl',

View file

@ -9,6 +9,7 @@ import {
TooManyRequestsAPIError,
} from '../../../core/errors/sync-errors';
import { WebDavHttpStatus } from './webdav.const';
import { Capacitor } from '@capacitor/core';
// Define and register our WebDAV plugin
interface WebDavHttpPluginRequest {
@ -55,23 +56,29 @@ export class WebDavHttpAdapter {
'PROPPATCH',
];
// Make IS_ANDROID_WEB_VIEW testable by making it a class property
// Make platform checks testable by making them class properties
protected get isAndroidWebView(): boolean {
return IS_ANDROID_WEB_VIEW;
}
protected get isNativePlatform(): boolean {
return Capacitor.isNativePlatform();
}
async request(options: WebDavHttpRequest): Promise<WebDavHttpResponse> {
try {
let response: WebDavHttpResponse;
if (this.isAndroidWebView) {
if (this.isNativePlatform) {
// Check if this is a WebDAV method
const isWebDavMethod = WebDavHttpAdapter.WEBDAV_METHODS.includes(
options.method.toUpperCase(),
);
if (isWebDavMethod) {
// Use our custom WebDAV plugin for WebDAV methods
// On Android, use custom WebDavHttp plugin for WebDAV methods (better retry handling)
// On iOS, use CapacitorHttp for all methods (including WebDAV)
if (isWebDavMethod && this.isAndroidWebView) {
// Use our custom WebDAV plugin for WebDAV methods on Android
PFLog.log(
`${WebDavHttpAdapter.L}.request() using WebDavHttp for ${options.method}`,
);
@ -83,7 +90,12 @@ export class WebDavHttpAdapter {
});
response = this._convertWebDavResponse(webdavResponse);
} else {
// Use standard CapacitorHttp for regular HTTP methods
// Use standard CapacitorHttp for:
// - All methods on iOS (including WebDAV)
// - Regular HTTP methods on Android
PFLog.log(
`${WebDavHttpAdapter.L}.request() using CapacitorHttp for ${options.method}`,
);
const capacitorResponse = await CapacitorHttp.request({
url: options.url,
method: options.method,

View file

@ -0,0 +1,19 @@
import { Capacitor } from '@capacitor/core';
import { IS_ANDROID_WEB_VIEW } from './is-android-web-view';
/**
* Whether running in a native Capacitor context (iOS or Android).
* This can be used in constants that are evaluated at module load time.
*/
export const IS_NATIVE_PLATFORM = Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW;
/**
* Whether running on iOS native (Capacitor).
*/
export const IS_IOS_NATIVE = Capacitor.getPlatform() === 'ios';
/**
* Whether running on Android native (Capacitor or WebView).
*/
export const IS_ANDROID_NATIVE =
Capacitor.getPlatform() === 'android' || IS_ANDROID_WEB_VIEW;