Create Purple.Scheduler

Thu, 24 Jul 2025 23:35:13 -0500

author
Gary Kramlich <grim@reaperworld.com>
date
Thu, 24 Jul 2025 23:35:13 -0500
changeset 43293
f5d33dbc18a9
parent 43292
03fe500d5aa5
child 43294
1056ce0140f2

Create Purple.Scheduler

This manages Purple.ScheduledTask instances and propagates their execute
signals.

This can be used for stuff like reconnecting accounts, scheduling messages
regardless of protocol support, changing avatars at a set time, really
whatever you can think of and write a plugin to implement.

Testing Done:
Ran the unit tests under valgrind and called in the turtles.

Also run in a dev environment and verified that there were no weird error messages.

Bugs closed: PIDGIN-18105

Reviewed at https://reviews.imfreedom.org/r/4073/

libpurple/core.c file | annotate | diff | comparison | revisions
libpurple/meson.build file | annotate | diff | comparison | revisions
libpurple/purplescheduler.c file | annotate | diff | comparison | revisions
libpurple/purplescheduler.h file | annotate | diff | comparison | revisions
libpurple/purpleschedulerprivate.h file | annotate | diff | comparison | revisions
libpurple/tests/meson.build file | annotate | diff | comparison | revisions
libpurple/tests/test_scheduler.c file | annotate | diff | comparison | revisions
--- a/libpurple/core.c	Thu Jul 24 23:33:18 2025 -0500
+++ b/libpurple/core.c	Thu Jul 24 23:35:13 2025 -0500
@@ -49,6 +49,7 @@
 #include "purplepath.h"
 #include "purplepresencemanagerprivate.h"
 #include "purpleprotocolmanagerprivate.h"
+#include "purpleschedulerprivate.h"
 #include "purplewhiteboardmanagerprivate.h"
 #include "util.h"
 #ifdef _WIN32
@@ -127,6 +128,7 @@
 
 	purple_ui_prefs_init(core->ui);
 
+	purple_scheduler_startup();
 	purple_notification_manager_startup();
 
 	purple_protocol_manager_startup();
@@ -234,6 +236,7 @@
 
 	purple_notification_manager_shutdown();
 	purple_history_manager_shutdown();
+	purple_scheduler_shutdown();
 
 	/* Everything after util_uninit cannot try to write things to the
 	 * confdir.
--- a/libpurple/meson.build	Thu Jul 24 23:33:18 2025 -0500
+++ b/libpurple/meson.build	Thu Jul 24 23:35:13 2025 -0500
@@ -71,6 +71,7 @@
 	'purpleproxyinfo.c',
 	'purplesavedpresence.c',
 	'purplescheduledtask.c',
+	'purplescheduler.c',
 	'purplesqlitehistoryadapter.c',
 	'purpletags.c',
 	'purpleui.c',
@@ -167,6 +168,7 @@
 	'purpleproxyinfo.h',
 	'purplesavedpresence.h',
 	'purplescheduledtask.h',
+	'purplescheduler.h',
 	'purplesqlitehistoryadapter.h',
 	'purpletags.h',
 	'purpletyping.h',
@@ -195,6 +197,7 @@
 	'purplenotificationmanagerprivate.h',
 	'purplepresencemanagerprivate.h',
 	'purpleprotocolmanagerprivate.h',
+	'purpleschedulerprivate.h',
 	'purplewhiteboardmanagerprivate.h',
 ]
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/purplescheduler.c	Thu Jul 24 23:35:13 2025 -0500
@@ -0,0 +1,437 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifdef G_LOG_DOMAIN
+# undef G_LOG_DOMAIN
+#endif
+#define G_LOG_DOMAIN "PurpleScheduler"
+
+#include <birb.h>
+
+#include "purplescheduler.h"
+#include "purpleschedulerprivate.h"
+
+struct _PurpleScheduler {
+	GObject parent;
+
+	GPtrArray *tasks;
+};
+
+enum {
+	PROP_0,
+	PROP_ITEM_TYPE,
+	PROP_N_ITEMS,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+enum {
+	SIG_EXECUTE_TASK,
+	N_SIGNALS,
+};
+static guint signals[N_SIGNALS] = {0, };
+
+G_DEFINE_QUARK(purple-scheduler-error, purple_scheduler_error)
+
+static PurpleScheduler *default_scheduler = NULL;
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+/**
+ * purple_scheduler_find_task_with_id: (skip)
+ * @id: the id of the task to search for
+ * @position: (out) (nullable): a return address for the position of the item
+ *
+ * Looks for a task with the given id.
+ *
+ * If the task is found it will be returned as well as it's position.
+ *
+ * Returns: (transfer none) (nullable): The task if found.
+ *
+ * Since: 3.0
+ */
+static PurpleScheduledTask *
+purple_scheduler_find_task_with_id(PurpleScheduler *scheduler,
+                                   const char *id,
+                                   guint *position)
+{
+	g_return_val_if_fail(PURPLE_IS_SCHEDULER(scheduler), NULL);
+
+	for(guint i = 0; i < scheduler->tasks->len; i++) {
+		PurpleScheduledTask *task = NULL;
+		const char *task_id = NULL;
+
+		task = g_ptr_array_index(scheduler->tasks, i);
+		task_id = purple_scheduled_task_get_id(task);
+
+		if(birb_str_equal(id, task_id)) {
+			if(position != NULL) {
+				*position = i;
+			}
+
+			return task;
+		}
+	}
+
+	return NULL;
+}
+
+/**
+ * purple_scheduler_unref_task: (skip)
+ * @task: the task to cancel and unref
+ *
+ * Cancels a task if necessary before unreferencing it.
+ *
+ * Since: 3.0
+ */
+static void
+purple_scheduler_unref_task(PurpleScheduledTask *task) {
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+
+	g_return_if_fail(PURPLE_IS_SCHEDULED_TASK(task));
+
+	state = purple_scheduled_task_get_state(task);
+	if(state == PURPLE_SCHEDULED_TASK_STATE_SCHEDULED) {
+		purple_scheduled_task_cancel(task);
+	}
+
+	g_object_unref(task);
+}
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+purple_scheduler_task_execute_cb(PurpleScheduledTask *task, gpointer data) {
+	PurpleScheduler *scheduler = data;
+	const char *task_type = NULL;
+
+	task_type = purple_scheduled_task_get_task_type(task);
+	g_signal_emit(scheduler,
+	              signals[SIG_EXECUTE_TASK],
+	              g_quark_from_string(task_type),
+	              task,
+	              task_type);
+}
+
+static void
+purple_scheduler_task_notify_state_cb(GObject *obj,
+                                      G_GNUC_UNUSED GParamSpec *pspec,
+                                      gpointer data)
+{
+	PurpleScheduledTask *task = PURPLE_SCHEDULED_TASK(obj);
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	PurpleScheduler *scheduler = data;
+
+	state = purple_scheduled_task_get_state(task);
+	if(state == PURPLE_SCHEDULED_TASK_STATE_EXECUTED) {
+		purple_scheduler_remove_task(scheduler,
+		                             purple_scheduled_task_get_id(task));
+	}
+}
+
+/******************************************************************************
+ * GListModel Implementation
+ *****************************************************************************/
+static GType
+purple_scheduler_get_item_type(G_GNUC_UNUSED GListModel *model) {
+	return PURPLE_TYPE_SCHEDULED_TASK;
+}
+
+static guint
+purple_scheduler_get_n_items(GListModel *list) {
+	PurpleScheduler *scheduler = PURPLE_SCHEDULER(list);
+
+	return scheduler->tasks->len;
+}
+
+static gpointer
+purple_scheduler_get_item(GListModel *list, guint position) {
+	PurpleScheduler *scheduler = PURPLE_SCHEDULER(list);
+	PurpleScheduledTask *task = NULL;
+
+	if(position < scheduler->tasks->len) {
+		task = g_ptr_array_index(scheduler->tasks, position);
+		g_object_ref(task);
+	}
+
+	return task;
+}
+
+static void
+purple_scheduler_list_model_init(GListModelInterface *iface) {
+	iface->get_item_type = purple_scheduler_get_item_type;
+	iface->get_n_items = purple_scheduler_get_n_items;
+	iface->get_item = purple_scheduler_get_item;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE_WITH_CODE(PurpleScheduler, purple_scheduler, G_TYPE_OBJECT,
+                              G_IMPLEMENT_INTERFACE(G_TYPE_LIST_MODEL,
+                                                    purple_scheduler_list_model_init));
+
+static void
+purple_scheduler_finalize(GObject *obj) {
+	PurpleScheduler *scheduler = PURPLE_SCHEDULER(obj);
+
+	g_clear_pointer(&scheduler->tasks, g_ptr_array_unref);
+
+	G_OBJECT_CLASS(purple_scheduler_parent_class)->finalize(obj);
+}
+
+static void
+purple_scheduler_get_property(GObject *obj, guint param_id, GValue *value,
+                              GParamSpec *pspec)
+{
+	GListModel *model = G_LIST_MODEL(obj);
+
+	switch(param_id) {
+	case PROP_ITEM_TYPE:
+		g_value_set_gtype(value, g_list_model_get_item_type(model));
+		break;
+	case PROP_N_ITEMS:
+		g_value_set_uint(value, g_list_model_get_n_items(model));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+purple_scheduler_init(PurpleScheduler *scheduler) {
+	scheduler->tasks = g_ptr_array_new_full(10,
+	                                        (GDestroyNotify)purple_scheduler_unref_task);
+}
+
+static void
+purple_scheduler_class_init(PurpleSchedulerClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = purple_scheduler_finalize;
+	obj_class->get_property = purple_scheduler_get_property;
+
+	/**
+	 * PurpleScheduler:item-type:
+	 *
+	 * The type of items. See [vfunc@Gio.ListModel.get_item_type].
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_ITEM_TYPE] = g_param_spec_gtype(
+		"item-type", NULL, NULL,
+		G_TYPE_OBJECT,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * PurpleScheduler:n-items:
+	 *
+	 * The number of items. See [vfunc@Gio.ListModel.get_n_items].
+	 *
+	 * Since: 3.0
+	 */
+	properties[PROP_N_ITEMS] = g_param_spec_uint(
+		"n-items", NULL, NULL,
+		0, G_MAXUINT, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+
+	/**
+	 * PurpleScheduler::execute-task:
+	 * @scheduler: the instance
+	 * @task: the task being executed
+	 * @task_type: the task type
+	 *
+	 * Emitted when a task is being executed.
+	 *
+	 * This signal supports details on [property@ScheduledTask:task-type] to
+	 * make it easier to listen for specific task types being executed.
+	 *
+	 * Since: 3.0
+	 */
+	signals[SIG_EXECUTE_TASK] = g_signal_new_class_handler(
+		"execute-task",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST | G_SIGNAL_DETAILED,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		2,
+		PURPLE_TYPE_SCHEDULED_TASK,
+		G_TYPE_STRING);
+}
+
+/******************************************************************************
+ * Private API
+ *****************************************************************************/
+void
+purple_scheduler_startup(void) {
+	if(!PURPLE_IS_SCHEDULER(default_scheduler)) {
+		default_scheduler = purple_scheduler_new();
+		g_object_add_weak_pointer(G_OBJECT(default_scheduler),
+		                          (gpointer *)&default_scheduler);
+	}
+}
+
+void
+purple_scheduler_shutdown(void) {
+	g_clear_object(&default_scheduler);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+gboolean
+purple_scheduler_add_task(PurpleScheduler *scheduler,
+                          PurpleScheduledTask *task,
+                          GDateTime *execute_at,
+                          GError **error)
+{
+	PurpleScheduledTask *existing = NULL;
+	GError *local_error = NULL;
+	const char *id = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_SCHEDULER(scheduler), FALSE);
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+	g_return_val_if_fail(execute_at != NULL, FALSE);
+
+	id = purple_scheduled_task_get_id(task);
+	existing = purple_scheduler_find_task_with_id(scheduler, id, NULL);
+	if(PURPLE_IS_SCHEDULED_TASK(existing)) {
+		g_set_error(error,
+		            PURPLE_SCHEDULER_ERROR,
+		            PURPLE_SCHEDULER_ERROR_TASK_EXISTS,
+		            "a task with id %s already exists",
+		            id);
+		return FALSE;
+	}
+
+	if(!purple_scheduled_task_schedule(task, execute_at, &local_error)) {
+		if(local_error != NULL) {
+			g_propagate_error(error, local_error);
+		} else {
+			g_set_error_literal(error,
+			                    PURPLE_SCHEDULER_ERROR,
+			                    PURPLE_SCHEDULER_ERROR_FAILED_TO_SCHEDULE,
+			                    "the task failed to schedule for an unknown "
+			                    "reason");
+		}
+
+		return FALSE;
+	}
+
+	/* Connect to the execute signal so we can propagate the signal. */
+	g_signal_connect_object(task,
+	                        "execute",
+	                        G_CALLBACK(purple_scheduler_task_execute_cb),
+	                        scheduler,
+	                        G_CONNECT_DEFAULT);
+
+	/* Add a handler to remove the task after it's been executed. */
+	g_signal_connect_object(task,
+	                        "notify::state",
+	                        G_CALLBACK(purple_scheduler_task_notify_state_cb),
+	                        scheduler,
+	                        G_CONNECT_DEFAULT);
+
+	/* Finally add the item and emit the items-changed signal. */
+	g_ptr_array_add(scheduler->tasks, g_object_ref(task));
+	g_list_model_items_changed(G_LIST_MODEL(scheduler),
+	                           scheduler->tasks->len - 1, 0, 1);
+
+	return TRUE;
+}
+
+gboolean
+purple_scheduler_add_task_relative(PurpleScheduler *scheduler,
+                                   PurpleScheduledTask *task,
+                                   GTimeSpan when,
+                                   GError **error)
+{
+	GDateTime *now = NULL;
+	GDateTime *execute_at = NULL;
+	gboolean ret = FALSE;
+
+	g_return_val_if_fail(PURPLE_IS_SCHEDULER(scheduler), FALSE);
+	g_return_val_if_fail(PURPLE_IS_SCHEDULED_TASK(task), FALSE);
+
+	now = g_date_time_new_now_local();
+	execute_at = g_date_time_add(now, when);
+	g_date_time_unref(now);
+
+	ret = purple_scheduler_add_task(scheduler, task, execute_at, error);
+
+	g_date_time_unref(execute_at);
+
+	return ret;
+}
+
+PurpleScheduler *
+purple_scheduler_get_default(void) {
+	return default_scheduler;
+}
+
+GListModel *
+purple_scheduler_get_default_as_model(void) {
+	if(G_IS_LIST_MODEL(default_scheduler)) {
+		return G_LIST_MODEL(default_scheduler);
+	}
+
+	return NULL;
+}
+
+PurpleScheduler *
+purple_scheduler_new(void) {
+	return g_object_new(PURPLE_TYPE_SCHEDULER, NULL);
+}
+
+gboolean
+purple_scheduler_remove_task(PurpleScheduler *scheduler, const char *id) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	guint position = 0;
+
+	g_return_val_if_fail(PURPLE_IS_SCHEDULER(scheduler), FALSE);
+	g_return_val_if_fail(!birb_str_is_empty(id), FALSE);
+
+	task = purple_scheduler_find_task_with_id(scheduler, id, &position);
+	if(!PURPLE_IS_SCHEDULED_TASK(task)) {
+		return FALSE;
+	}
+
+	state = purple_scheduled_task_get_state(task);
+	if(state == PURPLE_SCHEDULED_TASK_STATE_SCHEDULED) {
+		purple_scheduled_task_cancel(task);
+	}
+
+	g_ptr_array_remove_index(scheduler->tasks, position);
+
+	g_list_model_items_changed(G_LIST_MODEL(scheduler), position, 1, 0);
+
+	return TRUE;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/purplescheduler.h	Thu Jul 24 23:35:13 2025 -0500
@@ -0,0 +1,185 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_SCHEDULER_H
+#define PURPLE_SCHEDULER_H
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "purplescheduledtask.h"
+#include "purpleversion.h"
+
+G_BEGIN_DECLS
+
+#define PURPLE_SCHEDULER_ERROR purple_scheduler_error_quark()
+
+/**
+ * PurpleSchedulerError:
+ * @PURPLE_SCHEDULER_ERROR_TASK_EXISTS: the task has already been added to the
+ *  scheduler
+ * @PURPLE_SCHEDULER_ERROR_FAILED_TO_SCHEDULE: the task failed to schedule for
+ *  an unknown reason
+ *
+ * Error codes returned by the scheduler.
+ *
+ * Since: 3.0
+ */
+typedef enum {
+	PURPLE_SCHEDULER_ERROR_TASK_EXISTS PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+	PURPLE_SCHEDULER_ERROR_FAILED_TO_SCHEDULE PURPLE_AVAILABLE_ENUMERATOR_IN_3_0,
+} PurpleSchedulerError;
+
+/**
+ * purple_scheduler_error_quark:
+ *
+ * The error domain to identify errors with the scheduler.
+ *
+ * Returns: The error domain.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GQuark purple_scheduler_error_quark(void);
+
+/**
+ * PurpleScheduler:
+ *
+ * An object that manages a collection of [class@ScheduledTask].
+ *
+ * Since: 3.0
+ */
+
+#define PURPLE_TYPE_SCHEDULER (purple_scheduler_get_type())
+
+PURPLE_AVAILABLE_IN_3_0
+G_DECLARE_FINAL_TYPE(PurpleScheduler, purple_scheduler, PURPLE, SCHEDULER,
+                     GObject)
+
+/**
+ * purple_scheduler_add_task:
+ * @task: (transfer none): the task to add
+ * @execute_at: (transfer none): the date time for when the task should be
+ *              executed.
+ * @error: (out) (nullable): a return address for a #GError
+ *
+ * Adds a new task to the scheduler.
+ *
+ * If the task has already been added, false will be returned and @error will
+ * be set.
+ *
+ * If @task is already scheduled, it will be rescheduled to run at @execute_at.
+ *
+ * Returns: true if the task was added successfully.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduler_add_task(PurpleScheduler *scheduler, PurpleScheduledTask *task, GDateTime *execute_at, GError **error);
+
+/**
+ * purple_scheduler_add_task_relative:
+ * @task: (transfer none): the task to add
+ * @when: the relative time from now to execute the task
+ * @error: (out) (nullable): a return address for a #GError
+ *
+ * Adds a new task to the scheduler.
+ *
+ * If the task has already been added, false will be returned and @error will
+ * be set.
+ *
+ * If @task is already scheduled, it will be rescheduled to run at @when.
+ *
+ * This is a wrapper around [method@Scheduler.add_task] that will add @when to
+ * the current time for you.
+ *
+ * Returns: true if the task was added successfully.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduler_add_task_relative(PurpleScheduler *scheduler, PurpleScheduledTask *task, GTimeSpan when, GError **error);
+
+/**
+ * purple_scheduler_get_default:
+ *
+ * Gets the default scheduler.
+ *
+ * This can return %NULL if libpurple has not been initialized.
+ *
+ * Returns: (transfer none) (nullable): The default scheduler.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleScheduler *purple_scheduler_get_default(void);
+
+/**
+ * purple_scheduler_get_default_as_model:
+ *
+ * Gets the default scheduler cast to a [iface@Gio.ListModel].
+ *
+ * This can return %NULL if libpurple has not been initialized.
+ *
+ * Returns: (transfer none) (nullable): The default scheduler as a list model.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GListModel *purple_scheduler_get_default_as_model(void);
+
+/**
+ * purple_scheduler_new:
+ *
+ * Creates a new scheduler.
+ *
+ * This is typically only used internally but may be useful to others.
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleScheduler *purple_scheduler_new(void);
+
+/**
+ * purple_scheduler_remove_task:
+ * @id: the id of the task
+ *
+ * Removes a task from the scheduler.
+ *
+ * If the task is found and currently scheduled, it will be cancelled.
+ *
+ * Returns: true if the task was found and removed.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_scheduler_remove_task(PurpleScheduler *scheduler, const char *id);
+
+G_END_DECLS
+
+#endif /* PURPLE_SCHEDULER_H */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/purpleschedulerprivate.h	Thu Jul 24 23:35:13 2025 -0500
@@ -0,0 +1,56 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple is the legal property of its developers, whose names are too numerous
+ * to list here. Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_SCHEDULER_PRIVATE_H
+#define PURPLE_SCHEDULER_PRIVATE_H
+
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+/**
+ * purple_scheduler_startup: (skip)
+ *
+ * Starts up the scheduler by creating the default instance.
+ *
+ * Since: 3.0
+ */
+G_GNUC_INTERNAL
+void purple_scheduler_startup(void);
+
+/**
+ * purple_scheduler_shutdown: (skip)
+ *
+ * Shuts down the scheduler by destroying the default instance.
+ *
+ * Since: 3.0
+ */
+G_GNUC_INTERNAL
+void purple_scheduler_shutdown(void);
+
+G_END_DECLS
+
+#endif /* PURPLE_SCHEDULER_PRIVATE_H */
--- a/libpurple/tests/meson.build	Thu Jul 24 23:33:18 2025 -0500
+++ b/libpurple/tests/meson.build	Thu Jul 24 23:35:13 2025 -0500
@@ -54,6 +54,7 @@
     'request_page',
     'saved_presence',
     'scheduled_task',
+    'scheduler',
     'str',
     'tags',
     'util',
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libpurple/tests/test_scheduler.c	Thu Jul 24 23:35:13 2025 -0500
@@ -0,0 +1,489 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <birb.h>
+
+#include <purple.h>
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_purple_scheduler_new(void) {
+	PurpleScheduler *scheduler = NULL;
+
+	scheduler = purple_scheduler_new();
+	birb_assert_type(scheduler, PURPLE_TYPE_SCHEDULER);
+	g_assert_true(G_IS_LIST_MODEL(scheduler));
+
+	g_assert_finalize_object(scheduler);
+}
+
+static void
+test_purple_scheduler_properties(void) {
+	PurpleScheduler *scheduler = NULL;
+	GType item_type = G_TYPE_INVALID;
+	guint n_items = 0;
+
+	scheduler = g_object_new(
+		PURPLE_TYPE_SCHEDULER,
+		NULL);
+
+	g_object_get(
+		G_OBJECT(scheduler),
+		"item-type", &item_type,
+		"n-items", &n_items,
+		NULL);
+
+	g_assert_cmpuint(item_type, ==, PURPLE_TYPE_SCHEDULED_TASK);
+
+	g_assert_cmpuint(n_items, ==, 0);
+
+	g_assert_finalize_object(scheduler);
+}
+
+static void
+test_purple_scheduler_add_remove(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	PurpleScheduler *scheduler = NULL;
+	GDateTime *execute_at = NULL;
+	GDateTime *now = NULL;
+	GError *error = NULL;
+	guint counter = 0;
+	gboolean result = FALSE;
+
+	scheduler = purple_scheduler_new();
+	birb_assert_type(scheduler, PURPLE_TYPE_SCHEDULER);
+	g_assert_true(G_IS_LIST_MODEL(scheduler));
+
+	/* Create our execute_at time. */
+	now = g_date_time_new_now_local();
+	execute_at = g_date_time_add(now, 10 * G_TIME_SPAN_MINUTE);
+	g_clear_pointer(&now, g_date_time_unref);
+
+	/* Wire up our signals. */
+	birb_count_list_model_items_changed(G_LIST_MODEL(scheduler), &counter);
+
+	/* Create the task. */
+	task = purple_scheduled_task_new("test-scheduler", "Scheduler Tests",
+	                                 TRUE);
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	/* Make sure the task is unscheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Add the task to the scheduler. */
+	counter = 0;
+	result = purple_scheduler_add_task(scheduler, task, execute_at, &error);
+
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Make sure items changed was called once and that we have 1 item in the
+	 * list model.
+	 */
+	g_assert_cmpuint(counter, ==, 1);
+	birb_assert_list_model_n_items(scheduler, 1);
+
+	/* Make sure that the task got scheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Remove the task. */
+	counter = 0;
+
+	result = purple_scheduler_remove_task(scheduler,
+	                                      purple_scheduled_task_get_id(task));
+	g_assert_true(result);
+
+	/* Make sure items changed was called once and that the model empty. */
+	g_assert_cmpuint(counter, ==, 1);
+	birb_assert_list_model_n_items(scheduler, 0);
+
+	/* Make sure the that the task got cancelled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_CANCELLED);
+
+	/* After removal from the scheduler, only our reference should exist. */
+	g_assert_finalize_object(task);
+
+	/* Clean up the scheduler. */
+	g_assert_finalize_object(scheduler);
+
+	g_clear_pointer(&execute_at, g_date_time_unref);
+}
+
+static void
+test_purple_scheduler_add_already_scheduled(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduler *scheduler = NULL;
+	GDateTime *original_execute_at = NULL;
+	GDateTime *updated_execute_at = NULL;
+	GError *error = NULL;
+	gboolean result = FALSE;
+
+	scheduler = purple_scheduler_new();
+
+	/* Create the task. */
+	task = purple_scheduled_task_new("test-scheduler", "Scheduler Tests",
+	                                 TRUE);
+
+	/* Schedule the task and store the original execute_at. */
+	result = purple_scheduled_task_schedule_relative(task,
+	                                                 10 * G_TIME_SPAN_MILLISECOND,
+	                                                 &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	original_execute_at = purple_scheduled_task_get_execute_at(task);
+	if(original_execute_at != NULL) {
+		g_date_time_ref(original_execute_at);
+	}
+
+	/* Add the task to the scheduler. */
+	result = purple_scheduler_add_task_relative(scheduler,
+	                                            task,
+	                                            100 * G_TIME_SPAN_MILLISECOND,
+	                                            &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Get the execute time of the task and verify that it is not the same as
+	 * the original time.
+	 */
+	updated_execute_at = purple_scheduled_task_get_execute_at(task);
+	g_assert_false(birb_date_time_equal(original_execute_at,
+	                                    updated_execute_at));
+
+	/* Clean up everything. */
+	g_assert_finalize_object(scheduler);
+	g_assert_finalize_object(task);
+	g_clear_pointer(&original_execute_at, g_date_time_unref);
+}
+
+static void
+test_purple_scheduler_double_add(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	PurpleScheduler *scheduler = NULL;
+	GDateTime *execute_at = NULL;
+	GDateTime *now = NULL;
+	GError *error = NULL;
+	guint counter = 0;
+	gboolean result = FALSE;
+
+	scheduler = purple_scheduler_new();
+	birb_assert_type(scheduler, PURPLE_TYPE_SCHEDULER);
+	g_assert_true(G_IS_LIST_MODEL(scheduler));
+
+	/* Create our execute_at time. */
+	now = g_date_time_new_now_local();
+	execute_at = g_date_time_add(now, 10 * G_TIME_SPAN_MINUTE);
+	g_clear_pointer(&now, g_date_time_unref);
+
+	/* Wire up our signals. */
+	birb_count_list_model_items_changed(G_LIST_MODEL(scheduler), &counter);
+
+	/* Create the task. */
+	task = purple_scheduled_task_new("test-scheduler", "Scheduler Tests",
+	                                 TRUE);
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	/* Make sure the task is unscheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Add the task to the scheduler. */
+	counter = 0;
+	result = purple_scheduler_add_task(scheduler, task, execute_at, &error);
+
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Make sure items changed was called once and that we have 1 item in the
+	 * list model.
+	 */
+	g_assert_cmpuint(counter, ==, 1);
+	birb_assert_list_model_n_items(scheduler, 1);
+
+	/* Make sure that the task got scheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Now add the task again. */
+	counter = 0;
+	result = purple_scheduler_add_task(scheduler, task, execute_at, &error);
+	g_assert_error(error,
+	               PURPLE_SCHEDULER_ERROR,
+	               PURPLE_SCHEDULER_ERROR_TASK_EXISTS);
+	g_clear_error(&error);
+	g_assert_false(result);
+
+	/* Make sure the items-changed signal wasn't called and that we still only
+	 * have one item in the list.
+	 */
+	g_assert_cmpuint(counter, ==, 0);
+	birb_assert_list_model_n_items(scheduler, 1);
+
+	/* Cleanup. We don't remove the task because we want to make sure the
+	 * scheduler will cancel it when it shuts down. Also the task is last as
+	 * it's still known to the scheduler.
+	 */
+	g_assert_finalize_object(scheduler);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_CANCELLED);
+
+	g_assert_finalize_object(task);
+
+	g_clear_pointer(&execute_at, g_date_time_unref);
+}
+
+static void
+test_purple_scheduler_double_remove(void) {
+	PurpleScheduledTask *task = NULL;
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	PurpleScheduler *scheduler = NULL;
+	GDateTime *execute_at = NULL;
+	GDateTime *now = NULL;
+	GError *error = NULL;
+	guint counter = 0;
+	gboolean result = FALSE;
+
+	scheduler = purple_scheduler_new();
+	birb_assert_type(scheduler, PURPLE_TYPE_SCHEDULER);
+	g_assert_true(G_IS_LIST_MODEL(scheduler));
+
+	/* Create our execute_at time. */
+	now = g_date_time_new_now_local();
+	execute_at = g_date_time_add(now, 10 * G_TIME_SPAN_MINUTE);
+	g_clear_pointer(&now, g_date_time_unref);
+
+	/* Wire up our signals. */
+	birb_count_list_model_items_changed(G_LIST_MODEL(scheduler), &counter);
+
+	/* Create the task. */
+	task = purple_scheduled_task_new("test-scheduler", "Scheduler Tests",
+	                                 TRUE);
+	birb_assert_type(task, PURPLE_TYPE_SCHEDULED_TASK);
+
+	/* Make sure the task is unscheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED);
+
+	/* Add the task to the scheduler. */
+	counter = 0;
+	result = purple_scheduler_add_task(scheduler, task, execute_at, &error);
+
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Make sure items changed was called once and that we have 1 item in the
+	 * list model.
+	 */
+	g_assert_cmpuint(counter, ==, 1);
+	birb_assert_list_model_n_items(scheduler, 1);
+
+	/* Make sure that the task got scheduled. */
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_SCHEDULED);
+
+	/* Remove the task. */
+	counter = 0;
+	result = purple_scheduler_remove_task(scheduler,
+	                                      purple_scheduled_task_get_id(task));
+	g_assert_true(result);
+
+	/* Make sure the items-changed signal got called once and that we no longer
+	 * have any items in the model.
+	 */
+	g_assert_cmpuint(counter, ==, 1);
+	birb_assert_list_model_n_items(scheduler, 0);
+
+	/* Attempt to remove the task again. */
+	counter = 0;
+	result = purple_scheduler_remove_task(scheduler,
+	                                      purple_scheduled_task_get_id(task));
+	g_assert_false(result);
+
+	/* Make sure the items-changed signal wasn't called and that we still don't
+	 * have any items in the model.
+	 */
+	g_assert_cmpuint(counter, ==, 0);
+	birb_assert_list_model_n_items(scheduler, 0);
+
+	/* Cleanup. We remove task first because the scheduler shouldn't know about
+	 * it anymore.
+	 */
+	g_assert_finalize_object(task);
+	g_assert_finalize_object(scheduler);
+
+	g_clear_pointer(&execute_at, g_date_time_unref);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+static void
+test_purple_scheduler_execute_task_cb(PurpleScheduler *scheduler,
+                                      PurpleScheduledTask *task,
+                                      const char *task_type,
+                                      gpointer data)
+{
+	PurpleScheduledTaskState state = PURPLE_SCHEDULED_TASK_STATE_UNSCHEDULED;
+	const char *actual_task_type = NULL;
+	guint *counter = data;
+
+	birb_assert_type(scheduler, PURPLE_TYPE_SCHEDULER);
+
+	state = purple_scheduled_task_get_state(task);
+	g_assert_cmpuint(state, ==, PURPLE_SCHEDULED_TASK_STATE_EXECUTING);
+
+	actual_task_type = purple_scheduled_task_get_task_type(task);
+	g_assert_cmpstr(actual_task_type, ==, task_type);
+
+	*counter = *counter + 1;
+}
+
+static void
+test_purple_scheduler_signals_quit_cb(G_GNUC_UNUSED PurpleScheduler *scheduler,
+                                      G_GNUC_UNUSED PurpleScheduledTask *task,
+                                      G_GNUC_UNUSED const char *task_type,
+                                      gpointer data)
+{
+	g_main_loop_quit(data);
+}
+
+static void
+test_purple_scheduler_signals_timeout_cb(gpointer data) {
+	g_main_loop_quit(data);
+
+	g_assert_not_reached();
+}
+
+static void
+test_purple_scheduler_signals(void) {
+	PurpleScheduledTask *task1 = NULL;
+	PurpleScheduledTask *task2 = NULL;
+	PurpleScheduler *scheduler = NULL;
+	GError *error = NULL;
+	GMainLoop *loop = NULL;
+	guint all_counter = 0;
+	guint detailed_counter = 0;
+	gboolean result = FALSE;
+
+	/* This test creates 2 tasks, one for 10ms from now and the second for 20ms
+	 * from now. When the 20ms task is executed the main loop will be quit and
+	 * allow the rest of the test to finish.
+	 *
+	 * There is a 2 second timeout to make sure we don't hang the unit tests if
+	 * something unexpected happens. We use 2 seconds because internally the
+	 * tasks are scheduled with g_timeout_add_seconds which tries to schedule
+	 * timeouts together to avoid excessive CPU wake ups, so 2 seconds should
+	 * cover that.
+	 */
+
+	/* Create the counter and add our signal handlers with and without the
+	 * detail.
+	 */
+	scheduler = purple_scheduler_new();
+	g_signal_connect(scheduler,
+	                 "execute-task",
+	                 G_CALLBACK(test_purple_scheduler_execute_task_cb),
+	                 &all_counter);
+	g_signal_connect(scheduler,
+	                 "execute-task::scheduler-test-2",
+	                 G_CALLBACK(test_purple_scheduler_execute_task_cb),
+	                 &detailed_counter);
+
+	/* Add the first task. */
+	task1 = purple_scheduled_task_new("scheduler-test-1", "Scheduler Test 1",
+	                                  TRUE);
+	result = purple_scheduler_add_task_relative(scheduler,
+	                                            task1,
+	                                            10 * G_TIME_SPAN_MILLISECOND,
+	                                            &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Add the second task. */
+	task2 = purple_scheduled_task_new("scheduler-test-2", "Scheduler Test 2",
+	                                  FALSE);
+	result = purple_scheduler_add_task_relative(scheduler,
+	                                            task2,
+	                                            20 * G_TIME_SPAN_MILLISECOND,
+	                                            &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+
+	/* Create the main loop to run the tasks. */
+	loop = g_main_loop_new(NULL, FALSE);
+
+	/* Add a handler to quit the main loop when the second task is executed. */
+	g_signal_connect(scheduler,
+	                 "execute-task::scheduler-test-2",
+	                 G_CALLBACK(test_purple_scheduler_signals_quit_cb),
+	                 loop);
+
+	/* Add a timeout to avoid hangs on unexpected behavior. */
+	g_timeout_add_seconds_once(2, test_purple_scheduler_signals_timeout_cb,
+	                           loop);
+
+	/* Run the main loop. */
+	g_main_loop_run(loop);
+
+	g_clear_pointer(&loop, g_main_loop_unref);
+
+	/* Make sure the counters are correct. */
+	g_assert_cmpuint(all_counter, ==, 2);
+	g_assert_cmpuint(detailed_counter, ==, 1);
+
+	/* Make sure the scheduler is empty. */
+	birb_assert_list_model_n_items(scheduler, 0);
+
+	g_assert_finalize_object(scheduler);
+	g_assert_finalize_object(task1);
+	g_assert_finalize_object(task2);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/scheduler/new", test_purple_scheduler_new);
+	g_test_add_func("/scheduler/properties", test_purple_scheduler_properties);
+
+	g_test_add_func("/scheduler/add-remove", test_purple_scheduler_add_remove);
+	g_test_add_func("/scheduler/add-already-scheduled",
+	                test_purple_scheduler_add_already_scheduled);
+	g_test_add_func("/scheduler/double-add", test_purple_scheduler_double_add);
+	g_test_add_func("/scheduler/double-remove",
+	                test_purple_scheduler_double_remove);
+
+	g_test_add_func("/scheduler/signals", test_purple_scheduler_signals);
+
+	return g_test_run();
+}

mercurial