feat(sync): add custom cap http plugin for webdav methods

This commit is contained in:
Johannes Millan 2025-07-18 21:25:44 +02:00
parent 5c27dd2b5a
commit 84c24ebf79
8 changed files with 1299 additions and 19 deletions

View file

@ -0,0 +1,138 @@
package com.superproductivity.plugins.webdavhttp;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Iterator;
@CapacitorPlugin(name = "WebDavHttp")
public class WebDavHttpPlugin extends Plugin {
private static final String[] WEBDAV_METHODS = {
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"
};
private boolean isWebDavMethod(String method) {
for (String webdavMethod : WEBDAV_METHODS) {
if (webdavMethod.equalsIgnoreCase(method)) {
return true;
}
}
return false;
}
@PluginMethod
public void request(PluginCall call) {
String urlString = call.getString("url");
String method = call.getString("method", "GET");
JSObject headers = call.getObject("headers", new JSObject());
String data = call.getString("data");
if (urlString == null) {
call.reject("URL is required");
return;
}
try {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// For WebDAV methods, we need to use reflection since HttpURLConnection
// only supports standard HTTP methods
if (isWebDavMethod(method)) {
try {
// First set it as POST to allow output
connection.setRequestMethod("POST");
// Then use reflection to set the actual method
java.lang.reflect.Field methodField = HttpURLConnection.class.getDeclaredField("method");
methodField.setAccessible(true);
methodField.set(connection, method);
// Also update the delegate if using OkHttp
Object delegate = connection;
try {
java.lang.reflect.Field delegateField = connection.getClass().getDeclaredField("delegate");
delegateField.setAccessible(true);
delegate = delegateField.get(connection);
if (delegate != null) {
java.lang.reflect.Field delegateMethodField = delegate.getClass().getSuperclass().getDeclaredField("method");
delegateMethodField.setAccessible(true);
delegateMethodField.set(delegate, method);
}
} catch (Exception e) {
// OkHttp delegate not present, that's OK
}
} catch (Exception e) {
call.reject("Failed to set WebDAV method: " + method, e);
return;
}
} else {
// Standard HTTP method
connection.setRequestMethod(method);
}
// Set headers
Iterator<String> keys = headers.keys();
while (keys.hasNext()) {
String key = keys.next();
String value = headers.getString(key);
connection.setRequestProperty(key, value);
}
// Handle request body
if (data != null && !data.isEmpty()) {
connection.setDoOutput(true);
DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream());
outputStream.writeBytes(data);
outputStream.flush();
outputStream.close();
}
// Get response
int responseCode = connection.getResponseCode();
// Read response body
BufferedReader reader;
if (responseCode >= 200 && responseCode < 300) {
reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
} else {
reader = new BufferedReader(new InputStreamReader(connection.getErrorStream()));
}
StringBuilder responseBody = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
responseBody.append(line).append("\n");
}
reader.close();
// Get response headers
JSObject responseHeaders = new JSObject();
for (String headerKey : connection.getHeaderFields().keySet()) {
if (headerKey != null) {
responseHeaders.put(headerKey, connection.getHeaderField(headerKey));
}
}
// Return response
JSObject result = new JSObject();
result.put("data", responseBody.toString());
result.put("status", responseCode);
result.put("headers", responseHeaders);
result.put("url", connection.getURL().toString());
call.resolve(result);
} catch (Exception e) {
call.reject("Request failed", e);
}
}
}

View file

@ -13,6 +13,7 @@ import com.superproductivity.superproductivity.util.printWebViewVersion
import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
import com.superproductivity.superproductivity.plugins.SafBridgePlugin
import com.superproductivity.plugins.webdavhttp.WebDavHttpPlugin
/**
* All new Super-Productivity main activity, based on Capacitor to support offline use of the entire application
@ -26,7 +27,8 @@ class CapacitorMainActivity : BridgeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Register plugins before calling super.onCreate()
registerPlugin(SafBridgePlugin::class.java)
registerPlugin(WebDavHttpPlugin::class.java)
super.onCreate(savedInstanceState)
printWebViewVersion(bridge.webView)

922
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
export interface WebDavHttpPlugin {
request(options: WebDavHttpOptions): Promise<WebDavHttpResponse>;
}
export interface WebDavHttpOptions {
url: string;
method: string;
headers?: Record<string, string>;
data?: string | null;
responseType?: 'text' | 'json';
}
export interface WebDavHttpResponse {
data: string;
status: number;
headers: Record<string, string>;
url: string;
}

View file

@ -0,0 +1,9 @@
import { registerPlugin } from '@capacitor/core';
import type { WebDavHttpPlugin } from './definitions';
const WebDavHttp = registerPlugin<WebDavHttpPlugin>('WebDavHttp', {
web: () => import('./web').then((m) => new m.WebDavHttpWeb()),
});
export * from './definitions';
export { WebDavHttp };

View file

@ -0,0 +1,35 @@
import { WebPlugin } from '@capacitor/core';
import type {
WebDavHttpPlugin,
WebDavHttpOptions,
WebDavHttpResponse,
} from './definitions';
export class WebDavHttpWeb extends WebPlugin implements WebDavHttpPlugin {
async request(options: WebDavHttpOptions): Promise<WebDavHttpResponse> {
const { url, method, headers, data } = options;
// For web platform, we can use fetch with any HTTP method including WebDAV
const response = await fetch(url, {
method,
headers,
body: data || undefined,
});
// Get response headers
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// Get response data
const responseData = await response.text();
return {
data: responseData,
status: response.status,
headers: responseHeaders,
url: response.url,
};
}
}

View file

@ -5,6 +5,7 @@ import {
RemoteFileNotFoundAPIError,
TooManyRequestsAPIError,
} from '../../../errors/errors';
import { CapacitorHttp } from '@capacitor/core';
describe('WebDavHttpAdapter', () => {
let adapter: WebDavHttpAdapter;
@ -232,16 +233,135 @@ describe('WebDavHttpAdapter', () => {
});
});
// Skip CapacitorHttp tests as they require a proper Capacitor environment
// These would be better as integration tests
xdescribe('CapacitorHttp mode (Android)', () => {
// CapacitorHttp tests for Android WebView mode
describe('CapacitorHttp mode (Android)', () => {
let capacitorHttpSpy: jasmine.Spy;
beforeEach(() => {
adapter = new TestableWebDavHttpAdapter(true);
capacitorHttpSpy = spyOn(CapacitorHttp, 'request');
});
it('should use CapacitorHttp when in Android WebView mode', () => {
// This is a placeholder - real tests would require Capacitor environment
expect(adapter).toBeDefined();
it('should use CapacitorHttp for PROPFIND method', async () => {
const mockResponse = {
status: 207,
headers: { content_type: 'application/xml' },
data: '<xml>response</xml>',
url: 'http://example.com/test',
};
capacitorHttpSpy.and.returnValue(Promise.resolve(mockResponse));
const result = await adapter.request({
url: 'http://example.com/test',
method: 'PROPFIND',
headers: { CONTENT_TYPE: 'application/xml' },
body: '<xml>propfind</xml>',
});
expect(capacitorHttpSpy).toHaveBeenCalledWith({
url: 'http://example.com/test',
method: 'PROPFIND',
headers: { CONTENT_TYPE: 'application/xml' },
data: '<xml>propfind</xml>',
});
expect(result.status).toBe(207);
});
it('should use WebDavHttp for MKCOL method', async () => {
const mockResponse = {
status: 201,
headers: {},
data: '',
url: 'http://example.com/newfolder',
};
webDavHttpSpy.and.returnValue(Promise.resolve(mockResponse));
const result = await adapter.request({
url: 'http://example.com/newfolder',
method: 'MKCOL',
});
expect(webDavHttpSpy).toHaveBeenCalledWith({
url: 'http://example.com/newfolder',
method: 'MKCOL',
headers: undefined,
data: null,
});
expect(capacitorHttpSpy).not.toHaveBeenCalled();
expect(result.status).toBe(201);
});
it('should use CapacitorHttp for standard HTTP methods', async () => {
const mockResponse = {
status: 200,
headers: { content_type: 'text/plain' },
data: 'file content',
};
capacitorHttpSpy.and.returnValue(Promise.resolve(mockResponse));
const result = await adapter.request({
url: 'http://example.com/file.txt',
method: 'GET',
});
expect(capacitorHttpSpy).toHaveBeenCalledWith({
url: 'http://example.com/file.txt',
method: 'GET',
headers: undefined,
data: null,
});
expect(webDavHttpSpy).not.toHaveBeenCalled();
expect(result.status).toBe(200);
});
it('should handle WebDAV methods case-insensitively', async () => {
const mockResponse = {
status: 207,
headers: {},
data: '<xml/>',
url: 'http://example.com/test',
};
webDavHttpSpy.and.returnValue(Promise.resolve(mockResponse));
await adapter.request({
url: 'http://example.com/test',
method: 'propfind', // lowercase
});
expect(webDavHttpSpy).toHaveBeenCalled();
expect(capacitorHttpSpy).not.toHaveBeenCalled();
});
it('should handle all WebDAV methods', async () => {
const webDavMethods = [
'PROPFIND',
'MKCOL',
'MOVE',
'COPY',
'LOCK',
'UNLOCK',
'PROPPATCH',
];
const mockResponse = {
status: 200,
headers: {},
data: '',
url: 'http://example.com/test',
};
webDavHttpSpy.and.returnValue(Promise.resolve(mockResponse));
for (const method of webDavMethods) {
webDavHttpSpy.calls.reset();
capacitorHttpSpy.calls.reset();
await adapter.request({
url: 'http://example.com/test',
method: method,
});
expect(webDavHttpSpy).toHaveBeenCalled();
expect(capacitorHttpSpy).not.toHaveBeenCalled();
}
});
});
});

View file

@ -1,4 +1,4 @@
import { CapacitorHttp, HttpResponse } from '@capacitor/core';
import { CapacitorHttp, HttpResponse, registerPlugin } from '@capacitor/core';
import { IS_ANDROID_WEB_VIEW } from '../../../../../util/is-android-web-view';
import { PFLog } from '../../../../../core/log';
import {
@ -9,6 +9,13 @@ import {
} from '../../../errors/errors';
import { WebDavHttpStatus } from './webdav.const';
// Define and register our WebDAV plugin
interface WebDavHttpPlugin {
request(options: any): Promise<any>;
}
const WebDavHttp = registerPlugin<WebDavHttpPlugin>('WebDavHttp');
export interface WebDavHttpRequest {
url: string;
method: string;
@ -24,6 +31,15 @@ export interface WebDavHttpResponse {
export class WebDavHttpAdapter {
private static readonly L = 'WebDavHttpAdapter';
private static readonly WEBDAV_METHODS = [
'PROPFIND',
'MKCOL',
'MOVE',
'COPY',
'LOCK',
'UNLOCK',
'PROPPATCH',
];
// Make IS_ANDROID_WEB_VIEW testable by making it a class property
protected get isAndroidWebView(): boolean {
@ -35,15 +51,33 @@ export class WebDavHttpAdapter {
let response: WebDavHttpResponse;
if (this.isAndroidWebView) {
// Use CapacitorHttp for Android WebView
const capacitorResponse = await CapacitorHttp.request({
url: options.url,
method: options.method,
headers: options.headers,
data: options.body,
});
// Check if this is a WebDAV method
const isWebDavMethod = WebDavHttpAdapter.WEBDAV_METHODS.includes(
options.method.toUpperCase(),
);
response = this._convertCapacitorResponse(capacitorResponse);
if (isWebDavMethod) {
// Use our custom WebDAV plugin for WebDAV methods
PFLog.log(
`${WebDavHttpAdapter.L}.request() using WebDavHttp for ${options.method}`,
);
const webdavResponse = await WebDavHttp.request({
url: options.url,
method: options.method,
headers: options.headers,
data: options.body,
});
response = this._convertWebDavResponse(webdavResponse);
} else {
// Use standard CapacitorHttp for regular HTTP methods
const capacitorResponse = await CapacitorHttp.request({
url: options.url,
method: options.method,
headers: options.headers,
data: options.body,
});
response = this._convertCapacitorResponse(capacitorResponse);
}
} else {
// Use fetch for other platforms
const fetchResponse = await fetch(options.url, {
@ -91,6 +125,14 @@ export class WebDavHttpAdapter {
};
}
private _convertWebDavResponse(response: any): WebDavHttpResponse {
return {
status: response.status,
headers: response.headers || {},
data: response.data || '',
};
}
private async _convertFetchResponse(response: Response): Promise<WebDavHttpResponse> {
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {