diff --git a/src/calls-plugin-manager.c b/src/calls-plugin-manager.c
new file mode 100644
index 0000000..b327f48
--- /dev/null
+++ b/src/calls-plugin-manager.c
@@ -0,0 +1,424 @@
+/*
+ * Copyright (C) 2023 Purism SPC
+ *
+ * This file is part of Calls.
+ *
+ * Calls 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 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Calls 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 Calls. If not, see .
+ *
+ * Author: Evangelos Ribeiro Tzaras
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "CallsPluginManager"
+
+#include "calls-config.h"
+
+#include "calls-provider.h"
+#include "calls-plugin.h"
+#include "calls-plugin-manager.h"
+#include "calls-util.h"
+
+#include
+
+/**
+ * SECTION:plugin-manager
+ * @short_description: Manages available plugins
+ * @Title: CallsPluginManager
+ *
+ * #CallsPluginManager is a singleton that manages available plugins.
+ */
+
+struct _CallsPluginManager {
+ GObject parent_instance;
+
+ PeasEngine *plugin_engine;
+
+ GListStore *plugins;
+ GListStore *providers;
+};
+
+G_DEFINE_TYPE (CallsPluginManager, calls_plugin_manager, G_TYPE_OBJECT)
+
+enum {
+ PROP_0,
+ PROP_PROVIDERS,
+ PROP_PLUGINS,
+ PROP_LAST_PROP,
+};
+static GParamSpec *props[PROP_LAST_PROP];
+
+
+static CallsPlugin *
+find_plugin_by_name (CallsPluginManager *self,
+ const char *module_name,
+ guint *position)
+{
+ guint n_plugins;
+ GListModel *plugins;
+
+ g_assert (CALLS_IS_PLUGIN_MANAGER (self));
+
+ plugins = G_LIST_MODEL (self->plugins);
+ n_plugins = g_list_model_get_n_items (plugins);
+
+ for (guint i = 0; i < n_plugins; i++) {
+ g_autoptr (CallsPlugin) plugin = g_list_model_get_item (plugins, i);
+
+ if (g_strcmp0 (calls_plugin_get_module_name (plugin), module_name) == 0) {
+ if (position)
+ *position = i;
+
+ return plugin;
+ }
+ }
+
+ return NULL;
+}
+
+
+static void
+on_plugin_loaded (CallsPlugin *plugin,
+ GParamSpec *pspec,
+ CallsPluginManager *self)
+{
+ CallsProvider *provider = calls_plugin_get_provider (plugin);
+ gboolean loaded = calls_plugin_is_loaded (plugin);
+
+ if (!provider) {
+ g_debug ("Plugin '%s' was %sloaded, but no provider was found to %s",
+ calls_plugin_get_module_name (plugin),
+ loaded ? "" : "un",
+ loaded ? "add" : "remove");
+ return;
+ }
+
+ if (calls_plugin_is_loaded (plugin)) {
+ g_list_store_append (self->providers, provider);
+ } else {
+ guint pos;
+
+ g_list_store_find (self->providers, provider, &pos);
+ g_list_store_remove (self->providers, pos);
+ }
+}
+
+
+static void
+calls_plugin_manager_get_property (GObject *object,
+ guint property_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ CallsPluginManager *self = CALLS_PLUGIN_MANAGER (object);
+
+ switch (property_id) {
+ case PROP_PROVIDERS:
+ g_value_set_object (value, calls_plugin_manager_get_providers (self));
+ break;
+
+ case PROP_PLUGINS:
+ g_value_set_object (value, calls_plugin_manager_get_plugins (self));
+ break;
+
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
+ break;
+ }
+}
+
+
+static void
+unload_and_free_plugins (GListStore *plugins)
+{
+ GListModel *model = G_LIST_MODEL (plugins);
+ guint n = g_list_model_get_n_items (model);
+ for (guint i = 0; i < n; i++) {
+ g_autoptr (CallsPlugin) plugin = g_list_model_get_item (model, i);
+ g_autoptr (GError) error = NULL;
+
+ if (!calls_plugin_unload (plugin, &error))
+ g_warning ("Could not unload plugin '%s': %s",
+ calls_plugin_get_module_name (plugin),
+ error->message);
+ }
+
+ g_object_unref (plugins);
+}
+
+static void
+calls_plugin_manager_dispose (GObject *object)
+{
+ CallsPluginManager *self = CALLS_PLUGIN_MANAGER (object);
+
+ g_clear_pointer (&self->plugins, unload_and_free_plugins);
+ g_clear_object (&self->providers);
+ g_clear_object (&self->plugin_engine);
+
+ G_OBJECT_CLASS (calls_plugin_manager_parent_class)->dispose (object);
+}
+
+
+static void
+calls_plugin_manager_class_init (CallsPluginManagerClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->dispose = calls_plugin_manager_dispose;
+ object_class->get_property = calls_plugin_manager_get_property;
+
+ /**
+ * CallsPluginManager:providers:
+ *
+ * A #GListModel of #CallsProvider that are currently loaded.
+ */
+ props[PROP_PROVIDERS] =
+ g_param_spec_object ("providers",
+ "",
+ "",
+ G_TYPE_LIST_MODEL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * CallsPluginManager:plugins:
+ *
+ * A #GListModel of #CallsProvider that are currently loaded.
+ */
+ props[PROP_PLUGINS] =
+ g_param_spec_object ("plugins",
+ "",
+ "",
+ G_TYPE_LIST_MODEL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ g_object_class_install_properties (object_class, PROP_LAST_PROP, props);
+}
+
+
+static void
+calls_plugin_manager_init (CallsPluginManager *self)
+{
+ g_autofree char *default_plugin_dir_provider = NULL;
+ const char *dir;
+
+ self->plugin_engine = peas_engine_new ();
+
+ dir = g_getenv ("CALLS_PLUGIN_DIR");
+ if (!STR_IS_NULL_OR_EMPTY (dir)) {
+ g_autofree char *plugin_dir_provider = NULL;
+
+ plugin_dir_provider = g_build_filename (dir, "provider", NULL);
+
+ if (g_file_test (plugin_dir_provider, G_FILE_TEST_EXISTS)) {
+ g_debug ("Adding '%s' to plugin search path", plugin_dir_provider);
+ peas_engine_prepend_search_path (self->plugin_engine, plugin_dir_provider, NULL);
+ } else {
+ g_warning ("Not adding '%s' to plugin search path, because the directory doesn't exist.\n"
+ "Check if env CALLS_PLUGIN_DIR is set correctly", plugin_dir_provider);
+ }
+ }
+
+ default_plugin_dir_provider = g_build_filename (PLUGIN_LIBDIR, "provider", NULL);
+ peas_engine_add_search_path (self->plugin_engine, default_plugin_dir_provider, NULL);
+ g_debug ("Scanning for plugins in '%s'", default_plugin_dir_provider);
+
+ peas_engine_rescan_plugins (self->plugin_engine);
+
+ self->plugins = g_list_store_new (CALLS_TYPE_PLUGIN);
+
+ self->providers = g_list_store_new (CALLS_TYPE_PROVIDER);
+
+ for (const GList *node = peas_engine_get_plugin_list (self->plugin_engine); node; node = node->next) {
+ CallsPlugin *plugin = calls_plugin_new (node->data);
+
+ g_signal_connect_object (plugin,
+ "notify::loaded",
+ G_CALLBACK (on_plugin_loaded),
+ self,
+ G_CONNECT_AFTER);
+
+ g_list_store_append (self->plugins, plugin);
+ }
+}
+
+
+CallsPluginManager *
+calls_plugin_manager_get_default (void)
+{
+ static CallsPluginManager *instance;
+
+ if (instance == NULL) {
+ instance = g_object_new (CALLS_TYPE_PLUGIN_MANAGER, NULL);
+ g_object_add_weak_pointer (G_OBJECT (instance), (gpointer *) &instance);
+ }
+ return instance;
+}
+
+
+gboolean
+calls_plugin_manager_load_plugin (CallsPluginManager *self,
+ const char *name,
+ GError **error)
+{
+ CallsPlugin *plugin;
+
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), FALSE);
+ g_return_val_if_fail (!STR_IS_NULL_OR_EMPTY (name), FALSE);
+
+ plugin = find_plugin_by_name (self, name, NULL);
+
+ if (!plugin) {
+ g_set_error (error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
+ "No plugin '%s' found", name);
+ return FALSE;
+ }
+
+ return calls_plugin_load (plugin, error);
+}
+
+
+gboolean
+calls_plugin_manager_unload_plugin (CallsPluginManager *self,
+ const char *name,
+ GError **error)
+{
+ CallsPlugin *plugin;
+
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), FALSE);
+ g_return_val_if_fail (!STR_IS_NULL_OR_EMPTY (name), FALSE);
+
+ plugin = find_plugin_by_name (self, name, NULL);
+
+ if (!plugin) {
+ g_set_error (error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
+ "No plugin '%s' found", name);
+ return FALSE;
+ }
+
+ return calls_plugin_unload (plugin, error);
+}
+
+/**
+ * calls_plugin_manager_get_plugins:
+ * @self: The #CallsPluginManager
+ *
+ * Returns: (transfer none): A #GListModel of #[CallsPlugin]s
+ */
+GListModel *
+calls_plugin_manager_get_plugins (CallsPluginManager *self)
+{
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), NULL);
+
+ return G_LIST_MODEL (self->plugins);
+}
+/**
+ * calls_plugin_manager_get_providers:
+ * @self: The #CallsPluginManager
+ *
+ * Returns: (transfer none): A #GListModel of #[CallsProvider]s
+ */
+GListModel *
+calls_plugin_manager_get_providers (CallsPluginManager *self)
+{
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), NULL);
+
+ return G_LIST_MODEL (self->providers);
+}
+
+
+/**
+ * calls_plugin_manager_has_any_plugins:
+ * @self: The #CallsPluginManager
+ *
+ * Returns: %TRUE if any plugin is loaded, %FALSE otherwise.
+ */
+gboolean
+calls_plugin_manager_has_any_plugins (CallsPluginManager *self)
+{
+ guint n_plugins;
+
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), FALSE);
+
+ n_plugins = g_list_model_get_n_items (G_LIST_MODEL (self->plugins));
+
+ for (guint i = 0; i < n_plugins; i++) {
+ g_autoptr (CallsPlugin) plugin =
+ g_list_model_get_item (G_LIST_MODEL (self->plugins), i);
+
+ if (calls_plugin_is_loaded (plugin))
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * calls_plugin_manager_has_plugins:
+ * @self: The #CallsPluginManager
+ * @name: The name of the plugin to check
+ *
+ * Returns: %TRUE if a plugin with @name is loaded, %FALSE otherwise.
+ */
+gboolean
+calls_plugin_manager_has_plugin (CallsPluginManager *self,
+ const char *name)
+{
+ CallsPlugin *plugin;
+
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), FALSE);
+ g_return_val_if_fail (!STR_IS_NULL_OR_EMPTY (name), FALSE);
+
+ plugin = find_plugin_by_name (self, name, NULL);
+ if (!plugin)
+ return FALSE;
+
+ return calls_plugin_is_loaded (plugin);
+}
+
+
+/**
+ * calls_plugin_manager_get_plugin_names:
+ * @self: The #CallsPluginManager
+ * @length: (optional) (out): the length of the returned array
+ *
+ * Retrieves the names of all plugins loaded by @self, as an array.
+ *
+ * You should free the return value with g_free().
+ *
+ * Returns: (array length=length) (transfer container): a
+ * %NULL-terminated array containing the plugin names.
+ */
+const char **
+calls_plugin_manager_get_plugin_names (CallsPluginManager *self,
+ guint *length)
+{
+ GPtrArray *array;
+ guint n_items;
+
+ g_return_val_if_fail (CALLS_IS_PLUGIN_MANAGER (self), NULL);
+
+ n_items = g_list_model_get_n_items (G_LIST_MODEL (self->plugins));
+ if (length)
+ *length = n_items;
+ array = g_ptr_array_sized_new (n_items + 1);
+ array->pdata[n_items] = NULL;
+
+ for (guint i = 0; i < n_items; i++) {
+ g_autoptr (CallsPlugin) plugin =
+ g_list_model_get_item (G_LIST_MODEL (self->plugins), i);
+
+ array->pdata[i] = (gpointer) calls_plugin_get_module_name (plugin);
+ }
+
+ return (const char **) g_ptr_array_free (array, FALSE);
+}
diff --git a/src/calls-plugin-manager.h b/src/calls-plugin-manager.h
new file mode 100644
index 0000000..1bab668
--- /dev/null
+++ b/src/calls-plugin-manager.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2023 Purism SPC
+ *
+ * This file is part of Calls.
+ *
+ * Calls 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 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Calls 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 Calls. If not, see .
+ *
+ * Authors: Evangelos Ribeiro Tzaras
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include
+#include
+
+G_BEGIN_DECLS
+
+#define CALLS_TYPE_PLUGIN_MANAGER (calls_plugin_manager_get_type ())
+
+G_DECLARE_FINAL_TYPE (CallsPluginManager, calls_plugin_manager, CALLS, PLUGIN_MANAGER, GObject)
+
+
+CallsPluginManager *calls_plugin_manager_get_default (void);
+gboolean calls_plugin_manager_load_plugin (CallsPluginManager *self,
+ const char *name,
+ GError **error);
+gboolean calls_plugin_manager_unload_plugin (CallsPluginManager *self,
+ const char *name,
+ GError **error);
+const char **calls_plugin_manager_get_plugin_names (CallsPluginManager *self,
+ guint *length);
+gboolean calls_plugin_manager_has_plugin (CallsPluginManager *self,
+ const char *name);
+gboolean calls_plugin_manager_has_any_plugins (CallsPluginManager *self);
+GListModel *calls_plugin_manager_get_plugins (CallsPluginManager *self);
+GListModel *calls_plugin_manager_get_providers (CallsPluginManager *self);
+
+G_END_DECLS
diff --git a/src/meson.build b/src/meson.build
index e1d747f..06e495d 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -117,6 +117,7 @@ calls_sources = files([
'calls-notifier.c', 'calls-notifier.h',
'calls-origin.c', 'calls-origin.h',
'calls-plugin.c', 'calls-plugin.h',
+ 'calls-plugin-manager.c', 'calls-plugin-manager.h',
'calls-provider.c', 'calls-provider.h',
'calls-record-store.c', 'calls-record-store.h',
'calls-ringer.c', 'calls-ringer.h',